mirror of
https://github.com/mautrix/whatsapp.git
synced 2025-03-14 14:15:38 +00:00
all: delete legacy bridge
This commit is contained in:
parent
fc2db39bcb
commit
ea2d8ba07d
73 changed files with 53 additions and 16276 deletions
|
@ -1,6 +1,3 @@
|
||||||
include:
|
include:
|
||||||
- project: 'mautrix/ci'
|
- project: 'mautrix/ci'
|
||||||
file: '/gov2.yml'
|
file: '/gov2-as-default.yml'
|
||||||
|
|
||||||
variables:
|
|
||||||
BINARY_NAME_V2: mautrix-whatsapp
|
|
||||||
|
|
|
@ -17,21 +17,10 @@ repos:
|
||||||
- "maunium.net/go/mautrix-whatsapp"
|
- "maunium.net/go/mautrix-whatsapp"
|
||||||
- "-w"
|
- "-w"
|
||||||
- id: go-vet-repo-mod
|
- id: go-vet-repo-mod
|
||||||
# TODO switch to standard staticcheck after deleting old bridge
|
- id: go-staticcheck-repo-mod
|
||||||
#- id: go-staticcheck-repo-mod
|
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: go-staticcheck-custom
|
|
||||||
name: go-staticcheck-custom
|
|
||||||
language: system
|
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
entry: sh -c 'staticcheck $(go list ./cmd/... ./pkg/...)'
|
|
||||||
|
|
||||||
- repo: https://github.com/beeper/pre-commit-go
|
- repo: https://github.com/beeper/pre-commit-go
|
||||||
rev: v0.3.1
|
rev: v0.3.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: zerolog-ban-msgf
|
- id: zerolog-ban-msgf
|
||||||
# TODO enable after deleting old bridge
|
- id: zerolog-use-stringer
|
||||||
#- id: zerolog-use-stringer
|
|
||||||
|
|
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -1,3 +1,14 @@
|
||||||
|
# v0.11.0 (unreleased)
|
||||||
|
|
||||||
|
* Bumped minimum Go version to 1.22.
|
||||||
|
* Dropped support for unauthenticated media on Matrix.
|
||||||
|
* Rewrote bridge using bridgev2 architecture.
|
||||||
|
* It is recommended to check the config file after upgrading. If you have
|
||||||
|
prevented the bridge from writing to the config, you should update it
|
||||||
|
manually.
|
||||||
|
* Group management features and commands are not yet available.
|
||||||
|
* Polls are not yet supported.
|
||||||
|
|
||||||
# v0.10.9 (2024-07-16)
|
# v0.10.9 (2024-07-16)
|
||||||
|
|
||||||
* Added support for receiving Meta AI messages.
|
* Added support for receiving Meta AI messages.
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
FROM golang:1-alpine3.19 AS builder
|
FROM golang:1-alpine3.20 AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
||||||
|
|
||||||
COPY . /build
|
COPY . /build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN go build -o /usr/bin/mautrix-whatsapp
|
RUN ./build.sh
|
||||||
|
|
||||||
FROM alpine:3.19
|
FROM alpine:3.20
|
||||||
|
|
||||||
ENV UID=1337 \
|
ENV UID=1337 \
|
||||||
GID=1337
|
GID=1337
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/mautrix-whatsapp /usr/bin/mautrix-whatsapp
|
COPY --from=builder /build/mautrix-whatsapp /usr/bin/mautrix-whatsapp
|
||||||
COPY --from=builder /build/example-config.yaml /opt/mautrix-whatsapp/example-config.yaml
|
|
||||||
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
FROM alpine:3.19
|
FROM alpine:3.20
|
||||||
|
|
||||||
ENV UID=1337 \
|
ENV UID=1337 \
|
||||||
GID=1337
|
GID=1337
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go
|
||||||
|
|
||||||
ARG EXECUTABLE=./mautrix-whatsapp
|
ARG EXECUTABLE=./mautrix-whatsapp
|
||||||
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp
|
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp
|
||||||
COPY ./example-config.yaml /opt/mautrix-whatsapp/example-config.yaml
|
|
||||||
COPY ./docker-run.sh /docker-run.sh
|
COPY ./docker-run.sh /docker-run.sh
|
||||||
|
ENV BRIDGEV2=1
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
CMD ["/docker-run.sh"]
|
CMD ["/docker-run.sh"]
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
FROM golang:1-alpine3.18
|
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl
|
|
||||||
|
|
||||||
COPY . /build
|
|
||||||
WORKDIR /build
|
|
||||||
RUN go build -o /whatsapp
|
|
||||||
|
|
||||||
# Setup development stack using gow
|
|
||||||
RUN go install github.com/mitranim/gow@latest
|
|
||||||
RUN echo 'gow run /build $@' > /usr/bin/mautrix-whatsapp \
|
|
||||||
&& chmod +x /usr/bin/mautrix-whatsapp
|
|
||||||
VOLUME /data
|
|
|
@ -1,15 +0,0 @@
|
||||||
FROM alpine:3.20
|
|
||||||
|
|
||||||
ENV UID=1337 \
|
|
||||||
GID=1337
|
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go
|
|
||||||
|
|
||||||
ARG EXECUTABLE=./mautrix-whatsapp
|
|
||||||
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp
|
|
||||||
COPY ./docker-run.sh /docker-run.sh
|
|
||||||
ENV BRIDGEV2=1
|
|
||||||
VOLUME /data
|
|
||||||
WORKDIR /data
|
|
||||||
|
|
||||||
CMD ["/docker-run.sh"]
|
|
|
@ -10,6 +10,3 @@ The mautrix-whatsapp developers grant the following special exceptions:
|
||||||
All exceptions are only valid under the condition that any modifications to
|
All exceptions are only valid under the condition that any modifications to
|
||||||
the source code of mautrix-whatsapp remain publicly available under the terms
|
the source code of mautrix-whatsapp remain publicly available under the terms
|
||||||
of the GNU AGPL version 3 or later.
|
of the GNU AGPL version 3 or later.
|
||||||
|
|
||||||
These exceptions are only valid for the rewritten bridge under the `pkg` and
|
|
||||||
`cmd` directories, not the old bridge at the top level of the repository.
|
|
||||||
|
|
26
ROADMAP.md
26
ROADMAP.md
|
@ -46,19 +46,19 @@
|
||||||
* [x] Typing notifications
|
* [x] Typing notifications
|
||||||
* [x] Read receipts
|
* [x] Read receipts
|
||||||
* [x] Admin/superadmin status
|
* [x] Admin/superadmin status
|
||||||
* [ ] Membership actions
|
* [x] Membership actions
|
||||||
* [ ] Invite
|
* [x] Invite
|
||||||
* [ ] Join
|
* [x] Join
|
||||||
* [ ] Leave
|
* [x] Leave
|
||||||
* [ ] Kick
|
* [x] Kick
|
||||||
* [ ] Group metadata changes
|
* [x] Group metadata changes
|
||||||
* [ ] Title
|
* [x] Title
|
||||||
* [ ] Avatar
|
* [x] Avatar
|
||||||
* [ ] Description
|
* [x] Description
|
||||||
* [ ] Initial group metadata
|
* [x] Initial group metadata
|
||||||
* [ ] User metadata changes
|
* [x] User metadata changes
|
||||||
* [ ] Display name
|
* [x] Display name
|
||||||
* [ ] Avatar
|
* [x] Avatar
|
||||||
* [x] Initial user metadata
|
* [x] Initial user metadata
|
||||||
* [x] Display name
|
* [x] Display name
|
||||||
* [x] Avatar
|
* [x] Avatar
|
||||||
|
|
96
analytics.go
96
analytics.go
|
@ -1,96 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AnalyticsClient struct {
|
|
||||||
url string
|
|
||||||
key string
|
|
||||||
userID string
|
|
||||||
log zerolog.Logger
|
|
||||||
client http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var Analytics AnalyticsClient
|
|
||||||
|
|
||||||
func (sc *AnalyticsClient) trackSync(userID id.UserID, event string, properties map[string]interface{}) error {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
var analyticsUserID string
|
|
||||||
if Analytics.userID != "" {
|
|
||||||
analyticsUserID = Analytics.userID
|
|
||||||
} else {
|
|
||||||
analyticsUserID = userID.String()
|
|
||||||
}
|
|
||||||
err := json.NewEncoder(&buf).Encode(map[string]interface{}{
|
|
||||||
"userId": analyticsUserID,
|
|
||||||
"event": event,
|
|
||||||
"properties": properties,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, sc.url, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(sc.key, "")
|
|
||||||
resp, err := sc.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("unexpected status code %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *AnalyticsClient) IsEnabled() bool {
|
|
||||||
return len(sc.key) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *AnalyticsClient) Track(userID id.UserID, event string, properties ...map[string]interface{}) {
|
|
||||||
if !sc.IsEnabled() {
|
|
||||||
return
|
|
||||||
} else if len(properties) > 1 {
|
|
||||||
panic("Track should be called with at most one property map")
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
props := map[string]interface{}{}
|
|
||||||
if len(properties) > 0 {
|
|
||||||
props = properties[0]
|
|
||||||
}
|
|
||||||
props["bridge"] = "whatsapp"
|
|
||||||
err := sc.trackSync(userID, event, props)
|
|
||||||
if err != nil {
|
|
||||||
sc.log.Err(err).Str("event", event).Msg("Error tracking event")
|
|
||||||
} else {
|
|
||||||
sc.log.Debug().Str("event", event).Msg("Tracked event")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
136
backfillqueue.go
136
backfillqueue.go
|
@ -1,136 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BackfillQueue struct {
|
|
||||||
BackfillQuery *database.BackfillTaskQuery
|
|
||||||
reCheckChannels []chan bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillQueue) ReCheck() {
|
|
||||||
for _, channel := range bq.reCheckChannels {
|
|
||||||
go func(c chan bool) {
|
|
||||||
c <- true
|
|
||||||
}(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillQueue) GetNextBackfill(ctx context.Context, userID id.UserID, backfillTypes []database.BackfillType, waitForBackfillTypes []database.BackfillType, reCheckChannel chan bool) *database.BackfillTask {
|
|
||||||
for {
|
|
||||||
if !bq.BackfillQuery.HasUnstartedOrInFlightOfType(ctx, userID, waitForBackfillTypes) {
|
|
||||||
// check for immediate when dealing with deferred
|
|
||||||
if backfill, err := bq.BackfillQuery.GetNext(ctx, userID, backfillTypes); err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get next backfill task")
|
|
||||||
} else if backfill != nil {
|
|
||||||
err = backfill.MarkDispatched(ctx)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Warn().Err(err).
|
|
||||||
Int("queue_id", backfill.QueueID).
|
|
||||||
Msg("Failed to mark backfill task as dispatched")
|
|
||||||
}
|
|
||||||
return backfill
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-reCheckChannel:
|
|
||||||
case <-time.After(time.Minute):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) HandleBackfillRequestsLoop(backfillTypes []database.BackfillType, waitForBackfillTypes []database.BackfillType) {
|
|
||||||
log := user.zlog.With().
|
|
||||||
Str("action", "backfill request loop").
|
|
||||||
Any("types", backfillTypes).
|
|
||||||
Logger()
|
|
||||||
ctx := log.WithContext(context.TODO())
|
|
||||||
reCheckChannel := make(chan bool)
|
|
||||||
user.BackfillQueue.reCheckChannels = append(user.BackfillQueue.reCheckChannels, reCheckChannel)
|
|
||||||
|
|
||||||
for {
|
|
||||||
req := user.BackfillQueue.GetNextBackfill(ctx, user.MXID, backfillTypes, waitForBackfillTypes, reCheckChannel)
|
|
||||||
log.Info().Any("backfill_request", req).Msg("Handling backfill request")
|
|
||||||
log := log.With().
|
|
||||||
Int("queue_id", req.QueueID).
|
|
||||||
Stringer("portal_jid", req.Portal.JID).
|
|
||||||
Logger()
|
|
||||||
ctx := log.WithContext(ctx)
|
|
||||||
|
|
||||||
conv, err := user.bridge.DB.HistorySync.GetConversation(ctx, user.MXID, req.Portal)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to get conversation data for backfill request")
|
|
||||||
continue
|
|
||||||
} else if conv == nil {
|
|
||||||
log.Debug().Msg("Couldn't find conversation data for backfill request")
|
|
||||||
err = req.MarkDone(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to mark backfill request as done after data was not found")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
portal := user.GetPortalByJID(conv.PortalKey.JID)
|
|
||||||
|
|
||||||
// Update the client store with basic chat settings.
|
|
||||||
if conv.MuteEndTime.After(time.Now()) {
|
|
||||||
err = user.Client.Store.ChatSettings.PutMutedUntil(conv.PortalKey.JID, conv.MuteEndTime)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save muted until time from conversation data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if conv.Archived {
|
|
||||||
err = user.Client.Store.ChatSettings.PutArchived(conv.PortalKey.JID, true)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save archived state from conversation data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if conv.Pinned > 0 {
|
|
||||||
err = user.Client.Store.ChatSettings.PutPinned(conv.PortalKey.JID, true)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save pinned state from conversation data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if conv.EphemeralExpiration != nil && portal.ExpirationTime != *conv.EphemeralExpiration {
|
|
||||||
log.Debug().
|
|
||||||
Uint32("old_time", portal.ExpirationTime).
|
|
||||||
Uint32("new_time", *conv.EphemeralExpiration).
|
|
||||||
Msg("Updating portal ephemeral expiration time")
|
|
||||||
portal.ExpirationTime = *conv.EphemeralExpiration
|
|
||||||
err = portal.Update(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save portal after updating expiration time")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user.backfillInChunks(ctx, req, conv, portal)
|
|
||||||
err = req.MarkDone(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to mark backfill request as done after backfilling")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
101
bridgestate.go
101
bridgestate.go
|
@ -1,101 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2021 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/bridge/status"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
WALoggedOut status.BridgeStateErrorCode = "wa-logged-out"
|
|
||||||
WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone"
|
|
||||||
WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout"
|
|
||||||
WANotConnected status.BridgeStateErrorCode = "wa-not-connected"
|
|
||||||
WAConnecting status.BridgeStateErrorCode = "wa-connecting"
|
|
||||||
WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout"
|
|
||||||
WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline"
|
|
||||||
WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed"
|
|
||||||
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
|
|
||||||
WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.",
|
|
||||||
WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.",
|
|
||||||
WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.",
|
|
||||||
WANotConnected: "You're not connected to WhatsApp",
|
|
||||||
WAConnecting: "Reconnecting to WhatsApp...",
|
|
||||||
WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.",
|
|
||||||
WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
|
||||||
WAConnectionFailed: "Connecting to the WhatsApp web servers failed.",
|
|
||||||
WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) GetRemoteID() string {
|
|
||||||
if user == nil || user.JID.IsEmpty() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return user.JID.User
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) GetRemoteName() string {
|
|
||||||
if user == nil || user.JID.IsEmpty() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("+%s", user.JID.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !prov.bridge.AS.CheckServerToken(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
|
||||||
user := prov.bridge.GetUserByMXID(id.UserID(userID))
|
|
||||||
var global status.BridgeState
|
|
||||||
global.StateEvent = status.StateRunning
|
|
||||||
var remote status.BridgeState
|
|
||||||
if user.IsConnected() {
|
|
||||||
if user.Client.IsLoggedIn() {
|
|
||||||
remote.StateEvent = status.StateConnected
|
|
||||||
} else if user.Session != nil {
|
|
||||||
remote.StateEvent = status.StateConnecting
|
|
||||||
remote.Error = WAConnecting
|
|
||||||
} // else: unconfigured
|
|
||||||
} else if user.Session != nil {
|
|
||||||
remote.StateEvent = status.StateBadCredentials
|
|
||||||
remote.Error = WANotConnected
|
|
||||||
} // else: unconfigured
|
|
||||||
global = global.Fill(nil)
|
|
||||||
resp := status.GlobalBridgeState{
|
|
||||||
BridgeState: global,
|
|
||||||
RemoteStates: map[string]status.BridgeState{},
|
|
||||||
}
|
|
||||||
if len(remote.StateEvent) > 0 {
|
|
||||||
remote = remote.Fill(user)
|
|
||||||
resp.RemoteStates[remote.RemoteID] = remote
|
|
||||||
}
|
|
||||||
user.zlog.Debug().Any("response_data", &resp).Msg("Responding bridge state in bridge status endpoint")
|
|
||||||
jsonResponse(w, http.StatusOK, &resp)
|
|
||||||
if len(resp.RemoteStates) > 0 {
|
|
||||||
user.BridgeState.SetPrev(remote)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
|
||||||
GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
|
|
||||||
go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-whatsapp "$@"
|
|
4
build.sh
4
build.sh
|
@ -1,2 +1,4 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
|
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
||||||
|
GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
|
||||||
|
go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-whatsapp "$@"
|
||||||
|
|
1248
commands.go
1248
commands.go
File diff suppressed because it is too large
Load diff
337
config/bridge.go
337
config/bridge.go
|
@ -1,337 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2023 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeferredConfig struct {
|
|
||||||
StartDaysAgo int `yaml:"start_days_ago"`
|
|
||||||
MaxBatchEvents int `yaml:"max_batch_events"`
|
|
||||||
BatchDelay int `yaml:"batch_delay"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaRequestMethod string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MediaRequestMethodImmediate MediaRequestMethod = "immediate"
|
|
||||||
MediaRequestMethodLocalTime = "local_time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BridgeConfig struct {
|
|
||||||
UsernameTemplate string `yaml:"username_template"`
|
|
||||||
DisplaynameTemplate string `yaml:"displayname_template"`
|
|
||||||
|
|
||||||
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
|
|
||||||
|
|
||||||
DeliveryReceipts bool `yaml:"delivery_receipts"`
|
|
||||||
MessageStatusEvents bool `yaml:"message_status_events"`
|
|
||||||
MessageErrorNotices bool `yaml:"message_error_notices"`
|
|
||||||
PortalMessageBuffer int `yaml:"portal_message_buffer"`
|
|
||||||
CallStartNotices bool `yaml:"call_start_notices"`
|
|
||||||
IdentityChangeNotices bool `yaml:"identity_change_notices"`
|
|
||||||
|
|
||||||
HistorySync struct {
|
|
||||||
Backfill bool `yaml:"backfill"`
|
|
||||||
|
|
||||||
RequestFullSync bool `yaml:"request_full_sync"`
|
|
||||||
FullSyncConfig struct {
|
|
||||||
DaysLimit uint32 `yaml:"days_limit"`
|
|
||||||
SizeLimit uint32 `yaml:"size_mb_limit"`
|
|
||||||
StorageQuota uint32 `yaml:"storage_quota_mb"`
|
|
||||||
}
|
|
||||||
MaxInitialConversations int `yaml:"max_initial_conversations"`
|
|
||||||
MessageCount int `yaml:"message_count"`
|
|
||||||
UnreadHoursThreshold int `yaml:"unread_hours_threshold"`
|
|
||||||
|
|
||||||
Immediate struct {
|
|
||||||
WorkerCount int `yaml:"worker_count"`
|
|
||||||
MaxEvents int `yaml:"max_events"`
|
|
||||||
} `yaml:"immediate"`
|
|
||||||
|
|
||||||
MediaRequests struct {
|
|
||||||
AutoRequestMedia bool `yaml:"auto_request_media"`
|
|
||||||
RequestMethod MediaRequestMethod `yaml:"request_method"`
|
|
||||||
RequestLocalTime int `yaml:"request_local_time"`
|
|
||||||
MaxAsyncHandle int64 `yaml:"max_async_handle"`
|
|
||||||
} `yaml:"media_requests"`
|
|
||||||
|
|
||||||
Deferred []DeferredConfig `yaml:"deferred"`
|
|
||||||
} `yaml:"history_sync"`
|
|
||||||
UserAvatarSync bool `yaml:"user_avatar_sync"`
|
|
||||||
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
|
|
||||||
|
|
||||||
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
|
|
||||||
SyncManualMarkedUnread bool `yaml:"sync_manual_marked_unread"`
|
|
||||||
DefaultBridgePresence bool `yaml:"default_bridge_presence"`
|
|
||||||
SendPresenceOnTyping bool `yaml:"send_presence_on_typing"`
|
|
||||||
|
|
||||||
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
|
|
||||||
|
|
||||||
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
|
|
||||||
|
|
||||||
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
|
|
||||||
ParallelMemberSync bool `yaml:"parallel_member_sync"`
|
|
||||||
BridgeNotices bool `yaml:"bridge_notices"`
|
|
||||||
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
|
|
||||||
MuteBridging bool `yaml:"mute_bridging"`
|
|
||||||
ArchiveTag event.RoomTag `yaml:"archive_tag"`
|
|
||||||
PinnedTag event.RoomTag `yaml:"pinned_tag"`
|
|
||||||
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
|
|
||||||
MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"`
|
|
||||||
EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
|
|
||||||
MuteStatusBroadcast bool `yaml:"mute_status_broadcast"`
|
|
||||||
StatusBroadcastTag event.RoomTag `yaml:"status_broadcast_tag"`
|
|
||||||
WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
|
|
||||||
AllowUserInvite bool `yaml:"allow_user_invite"`
|
|
||||||
FederateRooms bool `yaml:"federate_rooms"`
|
|
||||||
URLPreviews bool `yaml:"url_previews"`
|
|
||||||
CaptionInMessage bool `yaml:"caption_in_message"`
|
|
||||||
BeeperGalleries bool `yaml:"beeper_galleries"`
|
|
||||||
ExtEvPolls bool `yaml:"extev_polls"`
|
|
||||||
CrossRoomReplies bool `yaml:"cross_room_replies"`
|
|
||||||
DisableReplyFallbacks bool `yaml:"disable_reply_fallbacks"`
|
|
||||||
|
|
||||||
MessageHandlingTimeout struct {
|
|
||||||
ErrorAfterStr string `yaml:"error_after"`
|
|
||||||
DeadlineStr string `yaml:"deadline"`
|
|
||||||
|
|
||||||
ErrorAfter time.Duration `yaml:"-"`
|
|
||||||
Deadline time.Duration `yaml:"-"`
|
|
||||||
} `yaml:"message_handling_timeout"`
|
|
||||||
|
|
||||||
DisableStatusBroadcastSend bool `yaml:"disable_status_broadcast_send"`
|
|
||||||
|
|
||||||
DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"`
|
|
||||||
CrashOnStreamReplaced bool `yaml:"crash_on_stream_replaced"`
|
|
||||||
|
|
||||||
CommandPrefix string `yaml:"command_prefix"`
|
|
||||||
|
|
||||||
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
|
|
||||||
|
|
||||||
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
|
|
||||||
|
|
||||||
Provisioning struct {
|
|
||||||
Prefix string `yaml:"prefix"`
|
|
||||||
SharedSecret string `yaml:"shared_secret"`
|
|
||||||
DebugEndpoints bool `yaml:"debug_endpoints"`
|
|
||||||
} `yaml:"provisioning"`
|
|
||||||
|
|
||||||
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
|
|
||||||
|
|
||||||
Relay RelaybotConfig `yaml:"relay"`
|
|
||||||
|
|
||||||
ParsedUsernameTemplate *template.Template `yaml:"-"`
|
|
||||||
displaynameTemplate *template.Template `yaml:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
|
|
||||||
return bc.DoublePuppetConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
|
|
||||||
return bc.Encryption
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) EnableMessageStatusEvents() bool {
|
|
||||||
return bc.MessageStatusEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) EnableMessageErrorNotices() bool {
|
|
||||||
return bc.MessageErrorNotices
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) GetCommandPrefix() string {
|
|
||||||
return bc.CommandPrefix
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
|
|
||||||
return bc.ManagementRoomText
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) GetResendBridgeInfo() bool {
|
|
||||||
return bc.ResendBridgeInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToInt(val bool) int {
|
|
||||||
if val {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) Validate() error {
|
|
||||||
_, hasWildcard := bc.Permissions["*"]
|
|
||||||
_, hasExampleDomain := bc.Permissions["example.com"]
|
|
||||||
_, hasExampleUser := bc.Permissions["@admin:example.com"]
|
|
||||||
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
|
|
||||||
if len(bc.Permissions) <= exampleLen {
|
|
||||||
return errors.New("bridge.permissions not configured")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type umBridgeConfig BridgeConfig
|
|
||||||
|
|
||||||
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
err := unmarshal((*umBridgeConfig)(bc))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bc.ParsedUsernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
|
|
||||||
return fmt.Errorf("username template is missing user ID placeholder")
|
|
||||||
}
|
|
||||||
|
|
||||||
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if bc.MessageHandlingTimeout.ErrorAfterStr != "" {
|
|
||||||
bc.MessageHandlingTimeout.ErrorAfter, err = time.ParseDuration(bc.MessageHandlingTimeout.ErrorAfterStr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bc.MessageHandlingTimeout.DeadlineStr != "" {
|
|
||||||
bc.MessageHandlingTimeout.Deadline, err = time.ParseDuration(bc.MessageHandlingTimeout.DeadlineStr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsernameTemplateArgs struct {
|
|
||||||
UserID id.UserID
|
|
||||||
}
|
|
||||||
|
|
||||||
type legacyContactInfo struct {
|
|
||||||
types.ContactInfo
|
|
||||||
Phone string
|
|
||||||
|
|
||||||
Notify string
|
|
||||||
VName string
|
|
||||||
Name string
|
|
||||||
Short string
|
|
||||||
JID string
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
NameQualityPush = 3
|
|
||||||
NameQualityContact = 2
|
|
||||||
NameQualityPhone = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
func (bc BridgeConfig) FormatDisplayname(jid types.JID, contact types.ContactInfo) (string, int8) {
|
|
||||||
var buf strings.Builder
|
|
||||||
_ = bc.displaynameTemplate.Execute(&buf, legacyContactInfo{
|
|
||||||
ContactInfo: contact,
|
|
||||||
Notify: contact.PushName,
|
|
||||||
VName: contact.BusinessName,
|
|
||||||
Name: contact.FullName,
|
|
||||||
Short: contact.FirstName,
|
|
||||||
Phone: "+" + jid.User,
|
|
||||||
JID: "+" + jid.User,
|
|
||||||
})
|
|
||||||
var quality int8
|
|
||||||
switch {
|
|
||||||
case len(contact.PushName) > 0 || len(contact.BusinessName) > 0:
|
|
||||||
quality = NameQualityPush
|
|
||||||
case len(contact.FullName) > 0 || len(contact.FirstName) > 0:
|
|
||||||
quality = NameQualityContact
|
|
||||||
default:
|
|
||||||
quality = NameQualityPhone
|
|
||||||
}
|
|
||||||
return buf.String(), quality
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bc BridgeConfig) FormatUsername(username string) string {
|
|
||||||
var buf strings.Builder
|
|
||||||
_ = bc.ParsedUsernameTemplate.Execute(&buf, username)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type RelaybotConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
AdminOnly bool `yaml:"admin_only"`
|
|
||||||
MessageFormats map[event.MessageType]string `yaml:"message_formats"`
|
|
||||||
messageTemplates *template.Template `yaml:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type umRelaybotConfig RelaybotConfig
|
|
||||||
|
|
||||||
func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
err := unmarshal((*umRelaybotConfig)(rc))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rc.messageTemplates = template.New("messageTemplates")
|
|
||||||
for key, format := range rc.MessageFormats {
|
|
||||||
_, err := rc.messageTemplates.New(string(key)).Parse(format)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sender struct {
|
|
||||||
UserID string
|
|
||||||
event.MemberEventContent
|
|
||||||
}
|
|
||||||
|
|
||||||
type formatData struct {
|
|
||||||
Sender Sender
|
|
||||||
Message string
|
|
||||||
Content *event.MessageEventContent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) {
|
|
||||||
if len(member.Displayname) == 0 {
|
|
||||||
member.Displayname = sender.String()
|
|
||||||
}
|
|
||||||
member.Displayname = template.HTMLEscapeString(member.Displayname)
|
|
||||||
var output strings.Builder
|
|
||||||
err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{
|
|
||||||
Sender: Sender{
|
|
||||||
UserID: template.HTMLEscapeString(sender.String()),
|
|
||||||
MemberEventContent: member,
|
|
||||||
},
|
|
||||||
Content: content,
|
|
||||||
Message: content.FormattedBody,
|
|
||||||
})
|
|
||||||
return output.String(), err
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
*bridgeconfig.BaseConfig `yaml:",inline"`
|
|
||||||
|
|
||||||
Analytics struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Token string `yaml:"token"`
|
|
||||||
UserID string `yaml:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
Metrics struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
Listen string `yaml:"listen"`
|
|
||||||
} `yaml:"metrics"`
|
|
||||||
|
|
||||||
WhatsApp struct {
|
|
||||||
OSName string `yaml:"os_name"`
|
|
||||||
BrowserName string `yaml:"browser_name"`
|
|
||||||
|
|
||||||
Proxy string `yaml:"proxy"`
|
|
||||||
GetProxyURL string `yaml:"get_proxy_url"`
|
|
||||||
ProxyOnlyLogin bool `yaml:"proxy_only_login"`
|
|
||||||
} `yaml:"whatsapp"`
|
|
||||||
|
|
||||||
Bridge BridgeConfig `yaml:"bridge"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
|
||||||
_, homeserver, _ := userID.Parse()
|
|
||||||
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
|
|
||||||
return hasSecret
|
|
||||||
}
|
|
|
@ -1,200 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
up "go.mau.fi/util/configupgrade"
|
|
||||||
"go.mau.fi/util/random"
|
|
||||||
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DoUpgrade(helper up.Helper) {
|
|
||||||
bridgeconfig.Upgrader.DoUpgrade(helper)
|
|
||||||
|
|
||||||
helper.Copy(up.Str|up.Null, "analytics", "host")
|
|
||||||
helper.Copy(up.Str|up.Null, "analytics", "token")
|
|
||||||
helper.Copy(up.Str|up.Null, "analytics", "user_id")
|
|
||||||
|
|
||||||
helper.Copy(up.Bool, "metrics", "enabled")
|
|
||||||
helper.Copy(up.Str, "metrics", "listen")
|
|
||||||
|
|
||||||
helper.Copy(up.Str, "whatsapp", "os_name")
|
|
||||||
helper.Copy(up.Str, "whatsapp", "browser_name")
|
|
||||||
helper.Copy(up.Str|up.Null, "whatsapp", "proxy")
|
|
||||||
helper.Copy(up.Str|up.Null, "whatsapp", "get_proxy_url")
|
|
||||||
helper.Copy(up.Bool, "whatsapp", "proxy_only_login")
|
|
||||||
|
|
||||||
helper.Copy(up.Str, "bridge", "username_template")
|
|
||||||
helper.Copy(up.Str, "bridge", "displayname_template")
|
|
||||||
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
|
|
||||||
helper.Copy(up.Bool, "bridge", "delivery_receipts")
|
|
||||||
helper.Copy(up.Bool, "bridge", "message_status_events")
|
|
||||||
helper.Copy(up.Bool, "bridge", "message_error_notices")
|
|
||||||
helper.Copy(up.Int, "bridge", "portal_message_buffer")
|
|
||||||
helper.Copy(up.Bool, "bridge", "call_start_notices")
|
|
||||||
helper.Copy(up.Bool, "bridge", "identity_change_notices")
|
|
||||||
helper.Copy(up.Bool, "bridge", "history_sync", "backfill")
|
|
||||||
helper.Copy(up.Bool, "bridge", "history_sync", "request_full_sync")
|
|
||||||
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "days_limit")
|
|
||||||
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "size_mb_limit")
|
|
||||||
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "storage_quota_mb")
|
|
||||||
helper.Copy(up.Bool, "bridge", "history_sync", "media_requests", "auto_request_media")
|
|
||||||
helper.Copy(up.Str, "bridge", "history_sync", "media_requests", "request_method")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "request_local_time")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "max_async_handle")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "max_initial_conversations")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "message_count")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "unread_hours_threshold")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "immediate", "worker_count")
|
|
||||||
helper.Copy(up.Int, "bridge", "history_sync", "immediate", "max_events")
|
|
||||||
helper.Copy(up.List, "bridge", "history_sync", "deferred")
|
|
||||||
helper.Copy(up.Bool, "bridge", "user_avatar_sync")
|
|
||||||
helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
|
|
||||||
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
|
|
||||||
helper.Copy(up.Bool, "bridge", "default_bridge_presence")
|
|
||||||
helper.Copy(up.Bool, "bridge", "send_presence_on_typing")
|
|
||||||
helper.Copy(up.Bool, "bridge", "force_active_delivery_receipts")
|
|
||||||
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
|
|
||||||
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
|
|
||||||
if legacySecret, ok := helper.Get(up.Str, "bridge", "login_shared_secret"); ok && len(legacySecret) > 0 {
|
|
||||||
baseNode := helper.GetBaseNode("bridge", "login_shared_secret_map")
|
|
||||||
baseNode.Map[helper.GetBase("homeserver", "domain")] = up.StringNode(legacySecret)
|
|
||||||
baseNode.UpdateContent()
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
|
|
||||||
}
|
|
||||||
if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
|
|
||||||
updatedPrivateChatPortalMeta := "default"
|
|
||||||
if legacyPrivateChatPortalMeta == "true" {
|
|
||||||
updatedPrivateChatPortalMeta = "always"
|
|
||||||
}
|
|
||||||
helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
|
|
||||||
}
|
|
||||||
helper.Copy(up.Bool, "bridge", "parallel_member_sync")
|
|
||||||
helper.Copy(up.Bool, "bridge", "bridge_notices")
|
|
||||||
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
|
|
||||||
helper.Copy(up.Bool, "bridge", "mute_bridging")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "archive_tag")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "pinned_tag")
|
|
||||||
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
|
|
||||||
helper.Copy(up.Bool, "bridge", "enable_status_broadcast")
|
|
||||||
helper.Copy(up.Bool, "bridge", "disable_status_broadcast_send")
|
|
||||||
helper.Copy(up.Bool, "bridge", "mute_status_broadcast")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "status_broadcast_tag")
|
|
||||||
helper.Copy(up.Bool, "bridge", "whatsapp_thumbnail")
|
|
||||||
helper.Copy(up.Bool, "bridge", "allow_user_invite")
|
|
||||||
helper.Copy(up.Str, "bridge", "command_prefix")
|
|
||||||
helper.Copy(up.Bool, "bridge", "federate_rooms")
|
|
||||||
helper.Copy(up.Bool, "bridge", "disable_bridge_alerts")
|
|
||||||
helper.Copy(up.Bool, "bridge", "crash_on_stream_replaced")
|
|
||||||
helper.Copy(up.Bool, "bridge", "url_previews")
|
|
||||||
helper.Copy(up.Bool, "bridge", "caption_in_message")
|
|
||||||
helper.Copy(up.Bool, "bridge", "beeper_galleries")
|
|
||||||
if intPolls, ok := helper.Get(up.Int, "bridge", "extev_polls"); ok {
|
|
||||||
val := "false"
|
|
||||||
if intPolls != "0" {
|
|
||||||
val = "true"
|
|
||||||
}
|
|
||||||
helper.Set(up.Bool, val, "bridge", "extev_polls")
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Bool, "bridge", "extev_polls")
|
|
||||||
}
|
|
||||||
helper.Copy(up.Bool, "bridge", "cross_room_replies")
|
|
||||||
helper.Copy(up.Bool, "bridge", "disable_reply_fallbacks")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "error_after")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "deadline")
|
|
||||||
|
|
||||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
|
|
||||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
|
|
||||||
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
|
|
||||||
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "allow")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "default")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "require")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
|
|
||||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
|
|
||||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
|
|
||||||
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
|
|
||||||
|
|
||||||
legacyKeyShareAllow, ok := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "allow")
|
|
||||||
if ok {
|
|
||||||
helper.Set(up.Bool, legacyKeyShareAllow, "bridge", "encryption", "allow_key_sharing")
|
|
||||||
legacyKeyShareRequireCS, legacyOK1 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_cross_signing")
|
|
||||||
legacyKeyShareRequireVerification, legacyOK2 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_verification")
|
|
||||||
if legacyOK1 && legacyOK2 && legacyKeyShareRequireVerification == "false" && legacyKeyShareRequireCS == "false" {
|
|
||||||
helper.Set(up.Str, "unverified", "bridge", "encryption", "verification_levels", "share")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
|
|
||||||
}
|
|
||||||
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
|
|
||||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
|
|
||||||
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
|
|
||||||
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
|
|
||||||
if prefix, ok := helper.Get(up.Str, "appservice", "provisioning", "prefix"); ok {
|
|
||||||
helper.Set(up.Str, strings.TrimSuffix(prefix, "/v1"), "bridge", "provisioning", "prefix")
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
|
|
||||||
}
|
|
||||||
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
|
|
||||||
if secret, ok := helper.Get(up.Str, "appservice", "provisioning", "shared_secret"); ok && secret != "generate" {
|
|
||||||
helper.Set(up.Str, secret, "bridge", "provisioning", "shared_secret")
|
|
||||||
} else if secret, ok = helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
|
|
||||||
sharedSecret := random.String(64)
|
|
||||||
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
|
|
||||||
} else {
|
|
||||||
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
|
|
||||||
}
|
|
||||||
helper.Copy(up.Map, "bridge", "permissions")
|
|
||||||
helper.Copy(up.Bool, "bridge", "relay", "enabled")
|
|
||||||
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
|
|
||||||
helper.Copy(up.Map, "bridge", "relay", "message_formats")
|
|
||||||
}
|
|
||||||
|
|
||||||
var SpacedBlocks = [][]string{
|
|
||||||
{"homeserver", "software"},
|
|
||||||
{"appservice"},
|
|
||||||
{"appservice", "hostname"},
|
|
||||||
{"appservice", "database"},
|
|
||||||
{"appservice", "id"},
|
|
||||||
{"appservice", "as_token"},
|
|
||||||
{"analytics"},
|
|
||||||
{"metrics"},
|
|
||||||
{"whatsapp"},
|
|
||||||
{"bridge"},
|
|
||||||
{"bridge", "command_prefix"},
|
|
||||||
{"bridge", "management_room_text"},
|
|
||||||
{"bridge", "encryption"},
|
|
||||||
{"bridge", "provisioning"},
|
|
||||||
{"bridge", "permissions"},
|
|
||||||
{"bridge", "relay"},
|
|
||||||
{"logging"},
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2021 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
|
|
||||||
puppet.CustomMXID = mxid
|
|
||||||
puppet.AccessToken = accessToken
|
|
||||||
puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
|
|
||||||
err := puppet.Update(context.TODO())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save access token: %w", err)
|
|
||||||
}
|
|
||||||
err = puppet.StartCustomMXID(false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// TODO leave rooms with default puppet
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) ClearCustomMXID() {
|
|
||||||
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
|
|
||||||
puppet.bridge.puppetsLock.Lock()
|
|
||||||
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
|
|
||||||
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
|
|
||||||
}
|
|
||||||
puppet.bridge.puppetsLock.Unlock()
|
|
||||||
puppet.CustomMXID = ""
|
|
||||||
puppet.AccessToken = ""
|
|
||||||
puppet.customIntent = nil
|
|
||||||
puppet.customUser = nil
|
|
||||||
if save {
|
|
||||||
err := puppet.Update(context.TODO())
|
|
||||||
if err != nil {
|
|
||||||
puppet.zlog.Err(err).Msg("Failed to clear custom MXID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
|
|
||||||
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(context.TODO(), puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
|
|
||||||
if err != nil {
|
|
||||||
puppet.ClearCustomMXID()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
puppet.bridge.puppetsLock.Lock()
|
|
||||||
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
|
|
||||||
puppet.bridge.puppetsLock.Unlock()
|
|
||||||
if puppet.AccessToken != newAccessToken {
|
|
||||||
puppet.AccessToken = newAccessToken
|
|
||||||
err = puppet.Update(context.TODO())
|
|
||||||
}
|
|
||||||
puppet.customIntent = newIntent
|
|
||||||
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) tryAutomaticDoublePuppeting() {
|
|
||||||
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.zlog.Debug().Msg("Checking if double puppeting needs to be enabled")
|
|
||||||
puppet := user.bridge.GetPuppetByJID(user.JID)
|
|
||||||
if len(puppet.CustomMXID) > 0 {
|
|
||||||
user.zlog.Debug().Msg("User already has double-puppeting enabled")
|
|
||||||
// Custom puppet already enabled
|
|
||||||
return
|
|
||||||
}
|
|
||||||
puppet.CustomMXID = user.MXID
|
|
||||||
puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
|
|
||||||
err := puppet.StartCustomMXID(true)
|
|
||||||
if err != nil {
|
|
||||||
user.zlog.Warn().Err(err).Msg("Failed to login with shared secret")
|
|
||||||
} else {
|
|
||||||
// TODO leave rooms with default puppet
|
|
||||||
user.zlog.Debug().Msg("Successfully automatically enabled custom puppet")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,253 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BackfillType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
BackfillImmediate BackfillType = 0
|
|
||||||
BackfillForward BackfillType = 100
|
|
||||||
BackfillDeferred BackfillType = 200
|
|
||||||
)
|
|
||||||
|
|
||||||
func (bt BackfillType) String() string {
|
|
||||||
switch bt {
|
|
||||||
case BackfillImmediate:
|
|
||||||
return "IMMEDIATE"
|
|
||||||
case BackfillForward:
|
|
||||||
return "FORWARD"
|
|
||||||
case BackfillDeferred:
|
|
||||||
return "DEFERRED"
|
|
||||||
}
|
|
||||||
return "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackfillTaskQuery struct {
|
|
||||||
*dbutil.QueryHelper[*BackfillTask]
|
|
||||||
|
|
||||||
//backfillQueryLock sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBackfillTask(qh *dbutil.QueryHelper[*BackfillTask]) *BackfillTask {
|
|
||||||
return &BackfillTask{qh: qh}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillTaskQuery) NewWithValues(userID id.UserID, backfillType BackfillType, priority int, portal PortalKey, timeStart *time.Time, maxBatchEvents, maxTotalEvents, batchDelay int) *BackfillTask {
|
|
||||||
return &BackfillTask{
|
|
||||||
qh: bq.QueryHelper,
|
|
||||||
|
|
||||||
UserID: userID,
|
|
||||||
BackfillType: backfillType,
|
|
||||||
Priority: priority,
|
|
||||||
Portal: portal,
|
|
||||||
TimeStart: timeStart,
|
|
||||||
MaxBatchEvents: maxBatchEvents,
|
|
||||||
MaxTotalEvents: maxTotalEvents,
|
|
||||||
BatchDelay: batchDelay,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getNextBackfillTaskQueryTemplate = `
|
|
||||||
SELECT queue_id, user_mxid, type, priority, portal_jid, portal_receiver, time_start, max_batch_events, max_total_events, batch_delay
|
|
||||||
FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND type IN (%s)
|
|
||||||
AND (
|
|
||||||
dispatch_time IS NULL
|
|
||||||
OR (
|
|
||||||
dispatch_time < $2
|
|
||||||
AND completed_at IS NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY type, priority, queue_id
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
getUnstartedOrInFlightBackfillTaskQueryTemplate = `
|
|
||||||
SELECT 1
|
|
||||||
FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND type IN (%s)
|
|
||||||
AND (dispatch_time IS NULL OR completed_at IS NULL)
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
deleteBackfillQueueForUserQuery = "DELETE FROM backfill_queue WHERE user_mxid=$1"
|
|
||||||
deleteBackfillQueueForPortalQuery = `
|
|
||||||
DELETE FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_jid=$2
|
|
||||||
AND portal_receiver=$3
|
|
||||||
`
|
|
||||||
insertBackfillTaskQuery = `
|
|
||||||
INSERT INTO backfill_queue (
|
|
||||||
user_mxid, type, priority, portal_jid, portal_receiver, time_start,
|
|
||||||
max_batch_events, max_total_events, batch_delay, dispatch_time, completed_at
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
RETURNING queue_id
|
|
||||||
`
|
|
||||||
markBackfillTaskDispatchedQuery = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
|
|
||||||
markBackfillTaskDoneQuery = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func typesToString(backfillTypes []BackfillType) string {
|
|
||||||
types := make([]string, len(backfillTypes))
|
|
||||||
for i, backfillType := range backfillTypes {
|
|
||||||
types[i] = strconv.Itoa(int(backfillType))
|
|
||||||
}
|
|
||||||
return strings.Join(types, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNext returns the next backfill to perform
|
|
||||||
func (bq *BackfillTaskQuery) GetNext(ctx context.Context, userID id.UserID, backfillTypes []BackfillType) (*BackfillTask, error) {
|
|
||||||
if len(backfillTypes) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
//bq.backfillQueryLock.Lock()
|
|
||||||
//defer bq.backfillQueryLock.Unlock()
|
|
||||||
|
|
||||||
query := fmt.Sprintf(getNextBackfillTaskQueryTemplate, typesToString(backfillTypes))
|
|
||||||
return bq.QueryOne(ctx, query, userID, time.Now().Add(-15*time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillTaskQuery) HasUnstartedOrInFlightOfType(ctx context.Context, userID id.UserID, backfillTypes []BackfillType) (has bool) {
|
|
||||||
if len(backfillTypes) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
//bq.backfillQueryLock.Lock()
|
|
||||||
//defer bq.backfillQueryLock.Unlock()
|
|
||||||
|
|
||||||
query := fmt.Sprintf(getUnstartedOrInFlightBackfillTaskQueryTemplate, typesToString(backfillTypes))
|
|
||||||
err := bq.GetDB().QueryRow(ctx, query, userID).Scan(&has)
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to check if backfill queue has jobs")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillTaskQuery) DeleteAll(ctx context.Context, userID id.UserID) error {
|
|
||||||
//bq.backfillQueryLock.Lock()
|
|
||||||
//defer bq.backfillQueryLock.Unlock()
|
|
||||||
return bq.Exec(ctx, deleteBackfillQueueForUserQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillTaskQuery) DeleteAllForPortal(ctx context.Context, userID id.UserID, portalKey PortalKey) error {
|
|
||||||
//bq.backfillQueryLock.Lock()
|
|
||||||
//defer bq.backfillQueryLock.Unlock()
|
|
||||||
return bq.Exec(ctx, deleteBackfillQueueForPortalQuery, userID, portalKey.JID, portalKey.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackfillTask struct {
|
|
||||||
qh *dbutil.QueryHelper[*BackfillTask]
|
|
||||||
|
|
||||||
QueueID int
|
|
||||||
UserID id.UserID
|
|
||||||
BackfillType BackfillType
|
|
||||||
Priority int
|
|
||||||
Portal PortalKey
|
|
||||||
TimeStart *time.Time
|
|
||||||
MaxBatchEvents int
|
|
||||||
MaxTotalEvents int
|
|
||||||
BatchDelay int
|
|
||||||
DispatchTime *time.Time
|
|
||||||
CompletedAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) MarshalZerologObject(evt *zerolog.Event) {
|
|
||||||
evt.Int("queue_id", b.QueueID).
|
|
||||||
Stringer("user_id", b.UserID).
|
|
||||||
Stringer("backfill_type", b.BackfillType).
|
|
||||||
Int("priority", b.Priority).
|
|
||||||
Stringer("portal_jid", b.Portal.JID).
|
|
||||||
Any("time_start", b.TimeStart).
|
|
||||||
Int("max_batch_events", b.MaxBatchEvents).
|
|
||||||
Int("max_total_events", b.MaxTotalEvents).
|
|
||||||
Int("batch_delay", b.BatchDelay).
|
|
||||||
Any("dispatch_time", b.DispatchTime).
|
|
||||||
Any("completed_at", b.CompletedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) String() string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"BackfillTask{QueueID: %d, UserID: %s, BackfillType: %s, Priority: %d, Portal: %s, TimeStart: %s, MaxBatchEvents: %d, MaxTotalEvents: %d, BatchDelay: %d, DispatchTime: %s, CompletedAt: %s}",
|
|
||||||
b.QueueID, b.UserID, b.BackfillType, b.Priority, b.Portal, b.TimeStart, b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.CompletedAt, b.DispatchTime,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) {
|
|
||||||
var maxTotalEvents, batchDelay sql.NullInt32
|
|
||||||
err := row.Scan(
|
|
||||||
&b.QueueID, &b.UserID, &b.BackfillType, &b.Priority, &b.Portal.JID, &b.Portal.Receiver, &b.TimeStart,
|
|
||||||
&b.MaxBatchEvents, &maxTotalEvents, &batchDelay,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
b.MaxTotalEvents = int(maxTotalEvents.Int32)
|
|
||||||
b.BatchDelay = int(batchDelay.Int32)
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) sqlVariables() []any {
|
|
||||||
return []any{
|
|
||||||
b.UserID, b.BackfillType, b.Priority, b.Portal.JID, b.Portal.Receiver, b.TimeStart,
|
|
||||||
b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.DispatchTime, b.CompletedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) Insert(ctx context.Context) error {
|
|
||||||
//b.db.Backfill.backfillQueryLock.Lock()
|
|
||||||
//defer b.db.Backfill.backfillQueryLock.Unlock()
|
|
||||||
|
|
||||||
return b.qh.GetDB().QueryRow(ctx, insertBackfillTaskQuery, b.sqlVariables()...).Scan(&b.QueueID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) MarkDispatched(ctx context.Context) error {
|
|
||||||
//b.db.Backfill.backfillQueryLock.Lock()
|
|
||||||
//defer b.db.Backfill.backfillQueryLock.Unlock()
|
|
||||||
|
|
||||||
if b.QueueID == 0 {
|
|
||||||
return fmt.Errorf("can't mark backfill as dispatched without queue_id")
|
|
||||||
}
|
|
||||||
return b.qh.Exec(ctx, markBackfillTaskDispatchedQuery, time.Now(), b.QueueID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillTask) MarkDone(ctx context.Context) error {
|
|
||||||
//b.db.Backfill.backfillQueryLock.Lock()
|
|
||||||
//defer b.db.Backfill.backfillQueryLock.Unlock()
|
|
||||||
|
|
||||||
if b.QueueID == 0 {
|
|
||||||
return fmt.Errorf("can't mark backfill as dispatched without queue_id")
|
|
||||||
}
|
|
||||||
return b.qh.Exec(ctx, markBackfillTaskDoneQuery, time.Now(), b.QueueID)
|
|
||||||
}
|
|
|
@ -1,94 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BackfillStateQuery struct {
|
|
||||||
*dbutil.QueryHelper[*BackfillState]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBackfillState(qh *dbutil.QueryHelper[*BackfillState]) *BackfillState {
|
|
||||||
return &BackfillState{qh: qh}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bq *BackfillStateQuery) NewBackfillState(userID id.UserID, portalKey PortalKey) *BackfillState {
|
|
||||||
return &BackfillState{
|
|
||||||
qh: bq.QueryHelper,
|
|
||||||
|
|
||||||
UserID: userID,
|
|
||||||
Portal: portalKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getBackfillStateQuery = `
|
|
||||||
SELECT user_mxid, portal_jid, portal_receiver, processing_batch, backfill_complete, first_expected_ts
|
|
||||||
FROM backfill_state
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_jid=$2
|
|
||||||
AND portal_receiver=$3
|
|
||||||
`
|
|
||||||
upsertBackfillStateQuery = `
|
|
||||||
INSERT INTO backfill_state
|
|
||||||
(user_mxid, portal_jid, portal_receiver, processing_batch, backfill_complete, first_expected_ts)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (user_mxid, portal_jid, portal_receiver)
|
|
||||||
DO UPDATE SET
|
|
||||||
processing_batch=EXCLUDED.processing_batch,
|
|
||||||
backfill_complete=EXCLUDED.backfill_complete,
|
|
||||||
first_expected_ts=EXCLUDED.first_expected_ts
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (bq *BackfillStateQuery) GetBackfillState(ctx context.Context, userID id.UserID, portalKey PortalKey) (*BackfillState, error) {
|
|
||||||
return bq.QueryOne(ctx, getBackfillStateQuery, userID, portalKey.JID, portalKey.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackfillState struct {
|
|
||||||
qh *dbutil.QueryHelper[*BackfillState]
|
|
||||||
|
|
||||||
UserID id.UserID
|
|
||||||
Portal PortalKey
|
|
||||||
ProcessingBatch bool
|
|
||||||
BackfillComplete bool
|
|
||||||
FirstExpectedTimestamp uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillState) Scan(row dbutil.Scannable) (*BackfillState, error) {
|
|
||||||
return dbutil.ValueOrErr(b, row.Scan(
|
|
||||||
&b.UserID, &b.Portal.JID, &b.Portal.Receiver, &b.ProcessingBatch, &b.BackfillComplete, &b.FirstExpectedTimestamp,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillState) sqlVariables() []any {
|
|
||||||
return []any{b.UserID, b.Portal.JID, b.Portal.Receiver, b.ProcessingBatch, b.BackfillComplete, b.FirstExpectedTimestamp}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillState) Upsert(ctx context.Context) error {
|
|
||||||
return b.qh.Exec(ctx, upsertBackfillStateQuery, b.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BackfillState) SetProcessingBatch(ctx context.Context, processing bool) error {
|
|
||||||
b.ProcessingBatch = processing
|
|
||||||
return b.Upsert(ctx)
|
|
||||||
}
|
|
|
@ -1,94 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
"go.mau.fi/whatsmeow/store"
|
|
||||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database/upgrades"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
sqlstore.PostgresArrayWrapper = pq.Array
|
|
||||||
}
|
|
||||||
|
|
||||||
type Database struct {
|
|
||||||
*dbutil.Database
|
|
||||||
|
|
||||||
User *UserQuery
|
|
||||||
Portal *PortalQuery
|
|
||||||
Puppet *PuppetQuery
|
|
||||||
Message *MessageQuery
|
|
||||||
Reaction *ReactionQuery
|
|
||||||
|
|
||||||
DisappearingMessage *DisappearingMessageQuery
|
|
||||||
BackfillQueue *BackfillTaskQuery
|
|
||||||
BackfillState *BackfillStateQuery
|
|
||||||
HistorySync *HistorySyncQuery
|
|
||||||
MediaBackfillRequest *MediaBackfillRequestQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *dbutil.Database) *Database {
|
|
||||||
db.UpgradeTable = upgrades.Table
|
|
||||||
return &Database{
|
|
||||||
Database: db,
|
|
||||||
User: &UserQuery{dbutil.MakeQueryHelper(db, newUser)},
|
|
||||||
Portal: &PortalQuery{dbutil.MakeQueryHelper(db, newPortal)},
|
|
||||||
Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)},
|
|
||||||
Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)},
|
|
||||||
Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)},
|
|
||||||
|
|
||||||
DisappearingMessage: &DisappearingMessageQuery{dbutil.MakeQueryHelper(db, newDisappearingMessage)},
|
|
||||||
BackfillQueue: &BackfillTaskQuery{dbutil.MakeQueryHelper(db, newBackfillTask)},
|
|
||||||
BackfillState: &BackfillStateQuery{dbutil.MakeQueryHelper(db, newBackfillState)},
|
|
||||||
HistorySync: &HistorySyncQuery{dbutil.MakeQueryHelper(db, newHistorySyncConversation)},
|
|
||||||
MediaBackfillRequest: &MediaBackfillRequestQuery{dbutil.MakeQueryHelper(db, newMediaBackfillRequest)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRetryableError(err error) bool {
|
|
||||||
if pqError := (&pq.Error{}); errors.As(err, &pqError) {
|
|
||||||
switch pqError.Code.Class() {
|
|
||||||
case "08", // Connection Exception
|
|
||||||
"53", // Insufficient Resources (e.g. too many connections)
|
|
||||||
"57": // Operator Intervention (e.g. server restart)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else if netError := (&net.OpError{}); errors.As(err, &netError) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Database) HandleSignalStoreError(device *store.Device, action string, attemptIndex int, err error) (retry bool) {
|
|
||||||
if db.Dialect != dbutil.SQLite && isRetryableError(err) {
|
|
||||||
sleepTime := time.Duration(attemptIndex*2) * time.Second
|
|
||||||
device.Log.Warnf("Failed to %s (attempt #%d): %v - retrying in %v", action, attemptIndex+1, err, sleepTime)
|
|
||||||
time.Sleep(sleepTime)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
device.Log.Errorf("Failed to %s: %v", action, err)
|
|
||||||
return false
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DisappearingMessageQuery struct {
|
|
||||||
*dbutil.QueryHelper[*DisappearingMessage]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDisappearingMessage(qh *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage {
|
|
||||||
return &DisappearingMessage{
|
|
||||||
qh: qh,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dmq *DisappearingMessageQuery) NewWithValues(roomID id.RoomID, eventID id.EventID, expireIn time.Duration, expireAt time.Time) *DisappearingMessage {
|
|
||||||
dm := &DisappearingMessage{
|
|
||||||
qh: dmq.QueryHelper,
|
|
||||||
|
|
||||||
RoomID: roomID,
|
|
||||||
EventID: eventID,
|
|
||||||
ExpireIn: expireIn,
|
|
||||||
ExpireAt: expireAt,
|
|
||||||
}
|
|
||||||
return dm
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllScheduledDisappearingMessagesQuery = `
|
|
||||||
SELECT room_id, event_id, expire_in, expire_at FROM disappearing_message WHERE expire_at IS NOT NULL AND expire_at <= $1
|
|
||||||
`
|
|
||||||
insertDisappearingMessageQuery = `INSERT INTO disappearing_message (room_id, event_id, expire_in, expire_at) VALUES ($1, $2, $3, $4)`
|
|
||||||
updateDisappearingMessageExpiryQuery = "UPDATE disappearing_message SET expire_at=$1 WHERE room_id=$2 AND event_id=$3"
|
|
||||||
deleteDisappearingMessageQuery = "DELETE FROM disappearing_message WHERE room_id=$1 AND event_id=$2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (dmq *DisappearingMessageQuery) GetUpcomingScheduled(ctx context.Context, duration time.Duration) ([]*DisappearingMessage, error) {
|
|
||||||
return dmq.QueryMany(ctx, getAllScheduledDisappearingMessagesQuery, time.Now().Add(duration).UnixMilli())
|
|
||||||
}
|
|
||||||
|
|
||||||
type DisappearingMessage struct {
|
|
||||||
qh *dbutil.QueryHelper[*DisappearingMessage]
|
|
||||||
|
|
||||||
RoomID id.RoomID
|
|
||||||
EventID id.EventID
|
|
||||||
ExpireIn time.Duration
|
|
||||||
ExpireAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
|
|
||||||
var expireIn int64
|
|
||||||
var expireAt sql.NullInt64
|
|
||||||
err := row.Scan(&msg.RoomID, &msg.EventID, &expireIn, &expireAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
msg.ExpireIn = time.Duration(expireIn) * time.Millisecond
|
|
||||||
if expireAt.Valid {
|
|
||||||
msg.ExpireAt = time.UnixMilli(expireAt.Int64)
|
|
||||||
}
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *DisappearingMessage) sqlVariables() []any {
|
|
||||||
return []any{msg.RoomID, msg.EventID, msg.ExpireIn.Milliseconds(), dbutil.UnixMilliPtr(msg.ExpireAt)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *DisappearingMessage) Insert(ctx context.Context) error {
|
|
||||||
return msg.qh.Exec(ctx, insertDisappearingMessageQuery, msg.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *DisappearingMessage) StartTimer(ctx context.Context) error {
|
|
||||||
msg.ExpireAt = time.Now().Add(msg.ExpireIn * time.Second)
|
|
||||||
return msg.qh.Exec(ctx, updateDisappearingMessageExpiryQuery, msg.ExpireAt.Unix(), msg.RoomID, msg.EventID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *DisappearingMessage) Delete(ctx context.Context) error {
|
|
||||||
return msg.qh.Exec(ctx, deleteDisappearingMessageQuery, msg.RoomID, msg.EventID)
|
|
||||||
}
|
|
|
@ -1,302 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HistorySyncQuery struct {
|
|
||||||
*dbutil.QueryHelper[*HistorySyncConversation]
|
|
||||||
}
|
|
||||||
|
|
||||||
type HistorySyncConversation struct {
|
|
||||||
qh *dbutil.QueryHelper[*HistorySyncConversation]
|
|
||||||
|
|
||||||
UserID id.UserID
|
|
||||||
ConversationID string
|
|
||||||
PortalKey PortalKey
|
|
||||||
LastMessageTimestamp time.Time
|
|
||||||
MuteEndTime time.Time
|
|
||||||
Archived bool
|
|
||||||
Pinned uint32
|
|
||||||
DisappearingMode waProto.DisappearingMode_Initiator
|
|
||||||
EndOfHistoryTransferType waProto.Conversation_EndOfHistoryTransferType
|
|
||||||
EphemeralExpiration *uint32
|
|
||||||
MarkedAsUnread bool
|
|
||||||
UnreadCount uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHistorySyncConversation(qh *dbutil.QueryHelper[*HistorySyncConversation]) *HistorySyncConversation {
|
|
||||||
return &HistorySyncConversation{
|
|
||||||
qh: qh,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) NewConversationWithValues(
|
|
||||||
userID id.UserID,
|
|
||||||
conversationID string,
|
|
||||||
portalKey PortalKey,
|
|
||||||
lastMessageTimestamp,
|
|
||||||
muteEndTime uint64,
|
|
||||||
archived bool,
|
|
||||||
pinned uint32,
|
|
||||||
disappearingMode waProto.DisappearingMode_Initiator,
|
|
||||||
endOfHistoryTransferType waProto.Conversation_EndOfHistoryTransferType,
|
|
||||||
ephemeralExpiration *uint32,
|
|
||||||
markedAsUnread bool,
|
|
||||||
unreadCount uint32,
|
|
||||||
) *HistorySyncConversation {
|
|
||||||
return &HistorySyncConversation{
|
|
||||||
qh: hsq.QueryHelper,
|
|
||||||
UserID: userID,
|
|
||||||
ConversationID: conversationID,
|
|
||||||
PortalKey: portalKey,
|
|
||||||
LastMessageTimestamp: time.Unix(int64(lastMessageTimestamp), 0).UTC(),
|
|
||||||
MuteEndTime: time.Unix(int64(muteEndTime), 0).UTC(),
|
|
||||||
Archived: archived,
|
|
||||||
Pinned: pinned,
|
|
||||||
DisappearingMode: disappearingMode,
|
|
||||||
EndOfHistoryTransferType: endOfHistoryTransferType,
|
|
||||||
EphemeralExpiration: ephemeralExpiration,
|
|
||||||
MarkedAsUnread: markedAsUnread,
|
|
||||||
UnreadCount: unreadCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
upsertHistorySyncConversationQuery = `
|
|
||||||
INSERT INTO history_sync_conversation (user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
||||||
ON CONFLICT (user_mxid, conversation_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
last_message_timestamp=CASE
|
|
||||||
WHEN EXCLUDED.last_message_timestamp > history_sync_conversation.last_message_timestamp THEN EXCLUDED.last_message_timestamp
|
|
||||||
ELSE history_sync_conversation.last_message_timestamp
|
|
||||||
END,
|
|
||||||
end_of_history_transfer_type=EXCLUDED.end_of_history_transfer_type
|
|
||||||
`
|
|
||||||
getNMostRecentConversations = `
|
|
||||||
SELECT user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count
|
|
||||||
FROM history_sync_conversation
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
ORDER BY last_message_timestamp DESC
|
|
||||||
LIMIT $2
|
|
||||||
`
|
|
||||||
getConversationByPortal = `
|
|
||||||
SELECT user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count
|
|
||||||
FROM history_sync_conversation
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_jid=$2
|
|
||||||
AND portal_receiver=$3
|
|
||||||
`
|
|
||||||
deleteAllConversationsQuery = "DELETE FROM history_sync_conversation WHERE user_mxid=$1"
|
|
||||||
deleteHistorySyncConversationQuery = `
|
|
||||||
DELETE FROM history_sync_conversation
|
|
||||||
WHERE user_mxid=$1 AND conversation_id=$2
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (hsc *HistorySyncConversation) sqlVariables() []any {
|
|
||||||
return []any{
|
|
||||||
hsc.UserID,
|
|
||||||
hsc.ConversationID,
|
|
||||||
hsc.PortalKey.JID,
|
|
||||||
hsc.PortalKey.Receiver,
|
|
||||||
hsc.LastMessageTimestamp,
|
|
||||||
hsc.Archived,
|
|
||||||
hsc.Pinned,
|
|
||||||
hsc.MuteEndTime,
|
|
||||||
hsc.DisappearingMode,
|
|
||||||
hsc.EndOfHistoryTransferType,
|
|
||||||
hsc.EphemeralExpiration,
|
|
||||||
hsc.MarkedAsUnread,
|
|
||||||
hsc.UnreadCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsc *HistorySyncConversation) Upsert(ctx context.Context) error {
|
|
||||||
return hsc.qh.Exec(ctx, upsertHistorySyncConversationQuery, hsc.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsc *HistorySyncConversation) Scan(row dbutil.Scannable) (*HistorySyncConversation, error) {
|
|
||||||
return dbutil.ValueOrErr(hsc, row.Scan(
|
|
||||||
&hsc.UserID,
|
|
||||||
&hsc.ConversationID,
|
|
||||||
&hsc.PortalKey.JID,
|
|
||||||
&hsc.PortalKey.Receiver,
|
|
||||||
&hsc.LastMessageTimestamp,
|
|
||||||
&hsc.Archived,
|
|
||||||
&hsc.Pinned,
|
|
||||||
&hsc.MuteEndTime,
|
|
||||||
&hsc.DisappearingMode,
|
|
||||||
&hsc.EndOfHistoryTransferType,
|
|
||||||
&hsc.EphemeralExpiration,
|
|
||||||
&hsc.MarkedAsUnread,
|
|
||||||
&hsc.UnreadCount,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) GetRecentConversations(ctx context.Context, userID id.UserID, n int) ([]*HistorySyncConversation, error) {
|
|
||||||
nPtr := &n
|
|
||||||
// Negative limit on SQLite means unlimited, but Postgres prefers a NULL limit.
|
|
||||||
if n < 0 && hsq.GetDB().Dialect == dbutil.Postgres {
|
|
||||||
nPtr = nil
|
|
||||||
}
|
|
||||||
return hsq.QueryMany(ctx, getNMostRecentConversations, userID, nPtr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) GetConversation(ctx context.Context, userID id.UserID, portalKey PortalKey) (*HistorySyncConversation, error) {
|
|
||||||
return hsq.QueryOne(ctx, getConversationByPortal, userID, portalKey.JID, portalKey.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) DeleteAllConversations(ctx context.Context, userID id.UserID) error {
|
|
||||||
return hsq.Exec(ctx, deleteAllConversationsQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
insertHistorySyncMessageQuery = `
|
|
||||||
INSERT INTO history_sync_message (user_mxid, conversation_id, message_id, timestamp, data, inserted_time)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (user_mxid, conversation_id, message_id) DO NOTHING
|
|
||||||
`
|
|
||||||
getHistorySyncMessagesBetweenQueryTemplate = `
|
|
||||||
SELECT data FROM history_sync_message
|
|
||||||
WHERE user_mxid=$1 AND conversation_id=$2
|
|
||||||
%s
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
%s
|
|
||||||
`
|
|
||||||
deleteHistorySyncMessagesBetweenExclusiveQuery = `
|
|
||||||
DELETE FROM history_sync_message
|
|
||||||
WHERE user_mxid=$1 AND conversation_id=$2 AND timestamp<$3 AND timestamp>$4
|
|
||||||
`
|
|
||||||
deleteAllHistorySyncMessagesQuery = "DELETE FROM history_sync_message WHERE user_mxid=$1"
|
|
||||||
deleteHistorySyncMessagesForPortalQuery = `
|
|
||||||
DELETE FROM history_sync_message
|
|
||||||
WHERE user_mxid=$1 AND conversation_id=$2
|
|
||||||
`
|
|
||||||
conversationHasHistorySyncMessagesQuery = `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM history_sync_message
|
|
||||||
WHERE user_mxid=$1 AND conversation_id=$2
|
|
||||||
)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
type HistorySyncMessage struct {
|
|
||||||
hsq *HistorySyncQuery
|
|
||||||
|
|
||||||
UserID id.UserID
|
|
||||||
ConversationID string
|
|
||||||
MessageID string
|
|
||||||
Timestamp time.Time
|
|
||||||
Data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) NewMessageWithValues(userID id.UserID, conversationID, messageID string, message *waProto.HistorySyncMsg) (*HistorySyncMessage, error) {
|
|
||||||
msgData, err := proto.Marshal(message)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &HistorySyncMessage{
|
|
||||||
hsq: hsq,
|
|
||||||
|
|
||||||
UserID: userID,
|
|
||||||
ConversationID: conversationID,
|
|
||||||
MessageID: messageID,
|
|
||||||
Timestamp: time.Unix(int64(message.Message.GetMessageTimestamp()), 0),
|
|
||||||
Data: msgData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsm *HistorySyncMessage) Insert(ctx context.Context) error {
|
|
||||||
return hsm.hsq.Exec(ctx, insertHistorySyncMessageQuery, hsm.UserID, hsm.ConversationID, hsm.MessageID, hsm.Timestamp, hsm.Data, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanWebMessageInfo(rows dbutil.Scannable) (*waProto.WebMessageInfo, error) {
|
|
||||||
var msgData []byte
|
|
||||||
err := rows.Scan(&msgData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var historySyncMsg waProto.HistorySyncMsg
|
|
||||||
err = proto.Unmarshal(msgData, &historySyncMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
|
|
||||||
}
|
|
||||||
return historySyncMsg.GetMessage(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) GetMessagesBetween(ctx context.Context, userID id.UserID, conversationID string, startTime, endTime *time.Time, limit int) ([]*waProto.WebMessageInfo, error) {
|
|
||||||
whereClauses := ""
|
|
||||||
args := []any{userID, conversationID}
|
|
||||||
argNum := 3
|
|
||||||
if startTime != nil {
|
|
||||||
whereClauses += fmt.Sprintf(" AND timestamp >= $%d", argNum)
|
|
||||||
args = append(args, startTime)
|
|
||||||
argNum++
|
|
||||||
}
|
|
||||||
if endTime != nil {
|
|
||||||
whereClauses += fmt.Sprintf(" AND timestamp <= $%d", argNum)
|
|
||||||
args = append(args, endTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
limitClause := ""
|
|
||||||
if limit > 0 {
|
|
||||||
limitClause = fmt.Sprintf("LIMIT %d", limit)
|
|
||||||
}
|
|
||||||
query := fmt.Sprintf(getHistorySyncMessagesBetweenQueryTemplate, whereClauses, limitClause)
|
|
||||||
|
|
||||||
return dbutil.ConvertRowFn[*waProto.WebMessageInfo](scanWebMessageInfo).
|
|
||||||
NewRowIter(hsq.GetDB().Query(ctx, query, args...)).
|
|
||||||
AsList()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) DeleteMessages(ctx context.Context, userID id.UserID, conversationID string, messages []*waProto.WebMessageInfo) error {
|
|
||||||
newest := messages[0]
|
|
||||||
beforeTS := time.Unix(int64(newest.GetMessageTimestamp())+1, 0)
|
|
||||||
oldest := messages[len(messages)-1]
|
|
||||||
afterTS := time.Unix(int64(oldest.GetMessageTimestamp())-1, 0)
|
|
||||||
return hsq.Exec(ctx, deleteHistorySyncMessagesBetweenExclusiveQuery, userID, conversationID, beforeTS, afterTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) DeleteAllMessages(ctx context.Context, userID id.UserID) error {
|
|
||||||
return hsq.Exec(ctx, deleteAllHistorySyncMessagesQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) DeleteAllMessagesForPortal(ctx context.Context, userID id.UserID, portalKey PortalKey) error {
|
|
||||||
return hsq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, userID, portalKey.JID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) ConversationHasMessages(ctx context.Context, userID id.UserID, portalKey PortalKey) (exists bool, err error) {
|
|
||||||
err = hsq.GetDB().QueryRow(ctx, conversationHasHistorySyncMessagesQuery, userID, portalKey.JID).Scan(&exists)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hsq *HistorySyncQuery) DeleteConversation(ctx context.Context, userID id.UserID, jid string) error {
|
|
||||||
// This will also clear history_sync_message as there's a foreign key constraint
|
|
||||||
return hsq.Exec(ctx, deleteHistorySyncConversationQuery, userID, jid)
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MediaBackfillRequestStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
MediaBackfillRequestStatusNotRequested MediaBackfillRequestStatus = iota
|
|
||||||
MediaBackfillRequestStatusRequested
|
|
||||||
MediaBackfillRequestStatusRequestFailed
|
|
||||||
)
|
|
||||||
|
|
||||||
type MediaBackfillRequestQuery struct {
|
|
||||||
*dbutil.QueryHelper[*MediaBackfillRequest]
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllMediaBackfillRequestsForUserQuery = `
|
|
||||||
SELECT user_mxid, portal_jid, portal_receiver, event_id, media_key, status, error
|
|
||||||
FROM media_backfill_requests
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND status=0
|
|
||||||
`
|
|
||||||
deleteAllMediaBackfillRequestsForUserQuery = "DELETE FROM media_backfill_requests WHERE user_mxid=$1"
|
|
||||||
upsertMediaBackfillRequestQuery = `
|
|
||||||
INSERT INTO media_backfill_requests (user_mxid, portal_jid, portal_receiver, event_id, media_key, status, error)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (user_mxid, portal_jid, portal_receiver, event_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
media_key=excluded.media_key,
|
|
||||||
status=excluded.status,
|
|
||||||
error=excluded.error
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (mbrq *MediaBackfillRequestQuery) GetMediaBackfillRequestsForUser(ctx context.Context, userID id.UserID) ([]*MediaBackfillRequest, error) {
|
|
||||||
return mbrq.QueryMany(ctx, getAllMediaBackfillRequestsForUserQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mbrq *MediaBackfillRequestQuery) DeleteAllMediaBackfillRequests(ctx context.Context, userID id.UserID) error {
|
|
||||||
return mbrq.Exec(ctx, deleteAllMediaBackfillRequestsForUserQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMediaBackfillRequest(qh *dbutil.QueryHelper[*MediaBackfillRequest]) *MediaBackfillRequest {
|
|
||||||
return &MediaBackfillRequest{
|
|
||||||
qh: qh,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mbrq *MediaBackfillRequestQuery) NewMediaBackfillRequestWithValues(userID id.UserID, portalKey PortalKey, eventID id.EventID, mediaKey []byte) *MediaBackfillRequest {
|
|
||||||
return &MediaBackfillRequest{
|
|
||||||
qh: mbrq.QueryHelper,
|
|
||||||
|
|
||||||
UserID: userID,
|
|
||||||
PortalKey: portalKey,
|
|
||||||
EventID: eventID,
|
|
||||||
MediaKey: mediaKey,
|
|
||||||
Status: MediaBackfillRequestStatusNotRequested,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaBackfillRequest struct {
|
|
||||||
qh *dbutil.QueryHelper[*MediaBackfillRequest]
|
|
||||||
|
|
||||||
UserID id.UserID
|
|
||||||
PortalKey PortalKey
|
|
||||||
EventID id.EventID
|
|
||||||
MediaKey []byte
|
|
||||||
Status MediaBackfillRequestStatus
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mbr *MediaBackfillRequest) Scan(row dbutil.Scannable) (*MediaBackfillRequest, error) {
|
|
||||||
return dbutil.ValueOrErr(mbr, row.Scan(&mbr.UserID, &mbr.PortalKey.JID, &mbr.PortalKey.Receiver, &mbr.EventID, &mbr.MediaKey, &mbr.Status, &mbr.Error))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mbr *MediaBackfillRequest) sqlVariables() []any {
|
|
||||||
return []any{mbr.UserID, mbr.PortalKey.JID, mbr.PortalKey.Receiver, mbr.EventID, mbr.MediaKey, mbr.Status, mbr.Error}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mbr *MediaBackfillRequest) Upsert(ctx context.Context) error {
|
|
||||||
return mbr.qh.Exec(ctx, upsertMediaBackfillRequestQuery, mbr.sqlVariables()...)
|
|
||||||
}
|
|
|
@ -1,199 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageQuery struct {
|
|
||||||
*dbutil.QueryHelper[*Message]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMessage(qh *dbutil.QueryHelper[*Message]) *Message {
|
|
||||||
return &Message{qh: qh}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllMessagesQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2
|
|
||||||
`
|
|
||||||
getMessageByJIDQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3
|
|
||||||
`
|
|
||||||
getMessageByMXIDQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE mxid=$1
|
|
||||||
`
|
|
||||||
getLastMessageInChatQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1
|
|
||||||
`
|
|
||||||
getFirstMessageInChatQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2 AND sent=true ORDER BY timestamp ASC LIMIT 1
|
|
||||||
`
|
|
||||||
getMessagesBetweenQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND sent=true AND error='' ORDER BY timestamp ASC
|
|
||||||
`
|
|
||||||
insertMessageQuery = `
|
|
||||||
INSERT INTO message
|
|
||||||
(chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
`
|
|
||||||
markMessageSentQuery = "UPDATE message SET sent=true, timestamp=$1 WHERE chat_jid=$2 AND chat_receiver=$3 AND jid=$4"
|
|
||||||
updateMessageMXIDQuery = "UPDATE message SET mxid=$1, type=$2, error=$3 WHERE chat_jid=$4 AND chat_receiver=$5 AND jid=$6"
|
|
||||||
deleteMessageQuery = "DELETE FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetAll(ctx context.Context, chat PortalKey) ([]*Message, error) {
|
|
||||||
return mq.QueryMany(ctx, getAllMessagesQuery, chat.JID, chat.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetByJID(ctx context.Context, chat PortalKey, jid types.MessageID) (*Message, error) {
|
|
||||||
return mq.QueryOne(ctx, getMessageByJIDQuery, chat.JID, chat.Receiver, jid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
|
|
||||||
return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetLastInChat(ctx context.Context, chat PortalKey) (*Message, error) {
|
|
||||||
return mq.GetLastInChatBefore(ctx, chat, time.Now().Add(60*time.Second))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetLastInChatBefore(ctx context.Context, chat PortalKey, maxTimestamp time.Time) (*Message, error) {
|
|
||||||
msg, err := mq.QueryOne(ctx, getLastMessageInChatQuery, chat.JID, chat.Receiver, maxTimestamp.Unix())
|
|
||||||
if msg != nil && msg.Timestamp.IsZero() {
|
|
||||||
// Old db, we don't know what the last message is.
|
|
||||||
msg = nil
|
|
||||||
}
|
|
||||||
return msg, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetFirstInChat(ctx context.Context, chat PortalKey) (*Message, error) {
|
|
||||||
return mq.QueryOne(ctx, getFirstMessageInChatQuery, chat.JID, chat.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mq *MessageQuery) GetMessagesBetween(ctx context.Context, chat PortalKey, minTimestamp, maxTimestamp time.Time) ([]*Message, error) {
|
|
||||||
return mq.QueryMany(ctx, getMessagesBetweenQuery, chat.JID, chat.Receiver, minTimestamp.Unix(), maxTimestamp.Unix())
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageErrorType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MsgNoError MessageErrorType = ""
|
|
||||||
MsgErrDecryptionFailed MessageErrorType = "decryption_failed"
|
|
||||||
MsgErrMediaNotFound MessageErrorType = "media_not_found"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MsgUnknown MessageType = ""
|
|
||||||
MsgFake MessageType = "fake"
|
|
||||||
MsgNormal MessageType = "message"
|
|
||||||
MsgReaction MessageType = "reaction"
|
|
||||||
MsgEdit MessageType = "edit"
|
|
||||||
MsgMatrixPoll MessageType = "matrix-poll"
|
|
||||||
MsgBeeperGallery MessageType = "beeper-gallery"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
qh *dbutil.QueryHelper[*Message]
|
|
||||||
|
|
||||||
Chat PortalKey
|
|
||||||
JID types.MessageID
|
|
||||||
MXID id.EventID
|
|
||||||
Sender types.JID
|
|
||||||
SenderMXID id.UserID
|
|
||||||
Timestamp time.Time
|
|
||||||
Sent bool
|
|
||||||
Type MessageType
|
|
||||||
Error MessageErrorType
|
|
||||||
|
|
||||||
GalleryPart int
|
|
||||||
|
|
||||||
BroadcastListJID types.JID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) IsFakeMXID() bool {
|
|
||||||
return strings.HasPrefix(msg.MXID.String(), "net.maunium.whatsapp.fake::")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) IsFakeJID() bool {
|
|
||||||
return strings.HasPrefix(msg.JID, "FAKE::") || msg.JID == string(msg.MXID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeGalleryMXIDFormat = "com.beeper.gallery::%d:%s"
|
|
||||||
|
|
||||||
func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) {
|
|
||||||
var ts int64
|
|
||||||
err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &msg.SenderMXID, &ts, &msg.Sent, &msg.Type, &msg.Error, &msg.BroadcastListJID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(msg.MXID.String(), "com.beeper.gallery::") {
|
|
||||||
_, err = fmt.Sscanf(msg.MXID.String(), fakeGalleryMXIDFormat, &msg.GalleryPart, &msg.MXID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse gallery MXID: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ts != 0 {
|
|
||||||
msg.Timestamp = time.Unix(ts, 0)
|
|
||||||
}
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) sqlVariables() []any {
|
|
||||||
mxid := msg.MXID.String()
|
|
||||||
if msg.GalleryPart != 0 {
|
|
||||||
mxid = fmt.Sprintf(fakeGalleryMXIDFormat, msg.GalleryPart, mxid)
|
|
||||||
}
|
|
||||||
return []any{msg.Chat.JID, msg.Chat.Receiver, msg.JID, mxid, msg.Sender, msg.SenderMXID, msg.Timestamp.Unix(), msg.Sent, msg.Type, msg.Error, msg.BroadcastListJID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) Insert(ctx context.Context) error {
|
|
||||||
return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) MarkSent(ctx context.Context, ts time.Time) error {
|
|
||||||
msg.Sent = true
|
|
||||||
msg.Timestamp = ts
|
|
||||||
return msg.qh.Exec(ctx, markMessageSentQuery, ts.Unix(), msg.Chat.JID, msg.Chat.Receiver, msg.JID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) UpdateMXID(ctx context.Context, mxid id.EventID, newType MessageType, newError MessageErrorType) error {
|
|
||||||
msg.MXID = mxid
|
|
||||||
msg.Type = newType
|
|
||||||
msg.Error = newError
|
|
||||||
return msg.qh.Exec(ctx, updateMessageMXIDQuery, mxid, newType, newError, msg.Chat.JID, msg.Chat.Receiver, msg.JID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) Delete(ctx context.Context) error {
|
|
||||||
return msg.qh.Exec(ctx, deleteMessageQuery, msg.Chat.JID, msg.Chat.Receiver, msg.JID)
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
bulkPutPollOptionsQuery = "INSERT INTO poll_option_id (msg_mxid, opt_id, opt_hash) VALUES ($1, $2, $3)"
|
|
||||||
bulkPutPollOptionsQueryTemplate = "($1, $%d, $%d)"
|
|
||||||
bulkPutPollOptionsQueryPlaceholder = "($1, $2, $3)"
|
|
||||||
getPollOptionIDsByHashesQuery = "SELECT opt_id, opt_hash FROM poll_option_id WHERE msg_mxid=$1 AND opt_hash = ANY($2)"
|
|
||||||
getPollOptionHashesByIDsQuery = "SELECT opt_id, opt_hash FROM poll_option_id WHERE msg_mxid=$1 AND opt_id = ANY($2)"
|
|
||||||
getPollOptionQuerySQLiteArrayTemplate = " IN (%s)"
|
|
||||||
getPollOptionQueryArrayPlaceholder = " = ANY($2)"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if strings.ReplaceAll(bulkPutPollOptionsQuery, bulkPutPollOptionsQueryPlaceholder, "meow") == bulkPutPollOptionsQuery {
|
|
||||||
panic("Bulk insert query placeholder not found")
|
|
||||||
}
|
|
||||||
if strings.ReplaceAll(getPollOptionIDsByHashesQuery, getPollOptionQueryArrayPlaceholder, "meow") == getPollOptionIDsByHashesQuery {
|
|
||||||
panic("Array select query placeholder not found")
|
|
||||||
}
|
|
||||||
if strings.ReplaceAll(getPollOptionHashesByIDsQuery, getPollOptionQueryArrayPlaceholder, "meow") == getPollOptionIDsByHashesQuery {
|
|
||||||
panic("Array select query placeholder not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type pollOption struct {
|
|
||||||
id string
|
|
||||||
hash [32]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanPollOption(rows dbutil.Scannable) (*pollOption, error) {
|
|
||||||
var hash []byte
|
|
||||||
var id string
|
|
||||||
err := rows.Scan(&id, &hash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if len(hash) != 32 {
|
|
||||||
return nil, fmt.Errorf("unexpected hash length %d", len(hash))
|
|
||||||
} else {
|
|
||||||
return &pollOption{id: id, hash: [32]byte(hash)}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) PutPollOptions(ctx context.Context, opts map[[32]byte]string) error {
|
|
||||||
args := make([]any, len(opts)*2+1)
|
|
||||||
placeholders := make([]string, len(opts))
|
|
||||||
args[0] = msg.MXID
|
|
||||||
i := 0
|
|
||||||
for hash, id := range opts {
|
|
||||||
args[i*2+1] = id
|
|
||||||
hashCopy := hash
|
|
||||||
args[i*2+2] = hashCopy[:]
|
|
||||||
placeholders[i] = fmt.Sprintf(bulkPutPollOptionsQueryTemplate, i*2+2, i*2+3)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
query := strings.ReplaceAll(bulkPutPollOptionsQuery, bulkPutPollOptionsQueryPlaceholder, strings.Join(placeholders, ","))
|
|
||||||
return msg.qh.Exec(ctx, query, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPollOptions[LookupKey any, Key comparable, Value any](
|
|
||||||
ctx context.Context,
|
|
||||||
msg *Message,
|
|
||||||
query string,
|
|
||||||
things []LookupKey,
|
|
||||||
getKeyValue func(option *pollOption) (Key, Value),
|
|
||||||
) (map[Key]Value, error) {
|
|
||||||
var args []any
|
|
||||||
if msg.qh.GetDB().Dialect == dbutil.Postgres {
|
|
||||||
args = []any{msg.MXID, pq.Array(things)}
|
|
||||||
} else {
|
|
||||||
query = strings.ReplaceAll(query, getPollOptionQueryArrayPlaceholder, fmt.Sprintf(getPollOptionQuerySQLiteArrayTemplate, strings.TrimSuffix(strings.Repeat("?,", len(things)), ",")))
|
|
||||||
args = make([]any, len(things)+1)
|
|
||||||
args[0] = msg.MXID
|
|
||||||
for i, thing := range things {
|
|
||||||
args[i+1] = thing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dbutil.RowIterAsMap(
|
|
||||||
dbutil.ConvertRowFn[*pollOption](scanPollOption).NewRowIter(msg.qh.GetDB().Query(ctx, query, args...)),
|
|
||||||
getKeyValue,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) GetPollOptionIDs(ctx context.Context, hashes [][]byte) (map[[32]byte]string, error) {
|
|
||||||
return getPollOptions(
|
|
||||||
ctx, msg, getPollOptionIDsByHashesQuery, hashes,
|
|
||||||
func(t *pollOption) ([32]byte, string) { return t.hash, t.id },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (msg *Message) GetPollOptionHashes(ctx context.Context, ids []string) (map[string][32]byte, error) {
|
|
||||||
return getPollOptions(
|
|
||||||
ctx, msg, getPollOptionHashesByIDsQuery, ids,
|
|
||||||
func(t *pollOption) (string, [32]byte) { return t.id, t.hash },
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,217 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PortalKey struct {
|
|
||||||
JID types.JID
|
|
||||||
Receiver types.JID
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPortalKey(jid, receiver types.JID) PortalKey {
|
|
||||||
if jid.Server == types.GroupServer || jid.Server == types.NewsletterServer {
|
|
||||||
receiver = jid
|
|
||||||
} else if jid.Server == types.LegacyUserServer {
|
|
||||||
jid.Server = types.DefaultUserServer
|
|
||||||
}
|
|
||||||
return PortalKey{
|
|
||||||
JID: jid.ToNonAD(),
|
|
||||||
Receiver: receiver.ToNonAD(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (key PortalKey) String() string {
|
|
||||||
if key.Receiver == key.JID {
|
|
||||||
return key.JID.String()
|
|
||||||
}
|
|
||||||
return key.JID.String() + "-" + key.Receiver.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type PortalQuery struct {
|
|
||||||
*dbutil.QueryHelper[*Portal]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal {
|
|
||||||
return &Portal{
|
|
||||||
qh: qh,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllPortalsQuery = `
|
|
||||||
SELECT jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
|
|
||||||
encrypted, last_sync, is_parent, parent_group, in_space,
|
|
||||||
first_event_id, next_batch_id, relay_user_id, expiration_time
|
|
||||||
FROM portal
|
|
||||||
`
|
|
||||||
getPortalByJIDQuery = getAllPortalsQuery + " WHERE jid=$1 AND receiver=$2"
|
|
||||||
getPortalByMXIDQuery = getAllPortalsQuery + " WHERE mxid=$1"
|
|
||||||
getPrivateChatsWithQuery = getAllPortalsQuery + " WHERE jid=$1"
|
|
||||||
getPrivateChatsOfQuery = getAllPortalsQuery + " WHERE receiver=$1"
|
|
||||||
getAllPortalsByParentGroupQuery = getAllPortalsQuery + " WHERE parent_group=$1"
|
|
||||||
findPrivateChatPortalsNotInSpaceQuery = `
|
|
||||||
SELECT jid FROM portal
|
|
||||||
LEFT JOIN user_portal ON portal.jid=user_portal.portal_jid AND portal.receiver=user_portal.portal_receiver
|
|
||||||
WHERE mxid<>'' AND receiver=$1 AND (user_portal.in_space=false OR user_portal.in_space IS NULL)
|
|
||||||
`
|
|
||||||
|
|
||||||
insertPortalQuery = `
|
|
||||||
INSERT INTO portal (
|
|
||||||
jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
|
|
||||||
encrypted, last_sync, is_parent, parent_group, in_space,
|
|
||||||
first_event_id, next_batch_id, relay_user_id, expiration_time
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
||||||
`
|
|
||||||
updatePortalQuery = `
|
|
||||||
UPDATE portal
|
|
||||||
SET mxid=$3, name=$4, name_set=$5, topic=$6, topic_set=$7, avatar=$8, avatar_url=$9, avatar_set=$10,
|
|
||||||
encrypted=$11, last_sync=$12, is_parent=$13, parent_group=$14, in_space=$15,
|
|
||||||
first_event_id=$16, next_batch_id=$17, relay_user_id=$18, expiration_time=$19
|
|
||||||
WHERE jid=$1 AND receiver=$2
|
|
||||||
`
|
|
||||||
clearPortalInSpaceQuery = "UPDATE portal SET in_space=false WHERE parent_group=$1"
|
|
||||||
deletePortalQuery = "DELETE FROM portal WHERE jid=$1 AND receiver=$2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) {
|
|
||||||
return pq.QueryMany(ctx, getAllPortalsQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) GetByJID(ctx context.Context, key PortalKey) (*Portal, error) {
|
|
||||||
return pq.QueryOne(ctx, getPortalByJIDQuery, key.JID, key.Receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
|
|
||||||
return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) GetAllByJID(ctx context.Context, jid types.JID) ([]*Portal, error) {
|
|
||||||
return pq.QueryMany(ctx, getPrivateChatsWithQuery, jid.ToNonAD())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) FindPrivateChats(ctx context.Context, receiver types.JID) ([]*Portal, error) {
|
|
||||||
return pq.QueryMany(ctx, getPrivateChatsOfQuery, receiver.ToNonAD())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) GetAllByParentGroup(ctx context.Context, jid types.JID) ([]*Portal, error) {
|
|
||||||
return pq.QueryMany(ctx, getAllPortalsByParentGroupQuery, jid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver types.JID) (keys []PortalKey, err error) {
|
|
||||||
receiver = receiver.ToNonAD()
|
|
||||||
scanFn := func(rows dbutil.Scannable) (key PortalKey, err error) {
|
|
||||||
key.Receiver = receiver
|
|
||||||
err = rows.Scan(&key.JID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return dbutil.ConvertRowFn[PortalKey](scanFn).
|
|
||||||
NewRowIter(pq.GetDB().Query(ctx, findPrivateChatPortalsNotInSpaceQuery, receiver)).
|
|
||||||
AsList()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Portal struct {
|
|
||||||
qh *dbutil.QueryHelper[*Portal]
|
|
||||||
|
|
||||||
Key PortalKey
|
|
||||||
MXID id.RoomID
|
|
||||||
|
|
||||||
Name string
|
|
||||||
NameSet bool
|
|
||||||
Topic string
|
|
||||||
TopicSet bool
|
|
||||||
Avatar string
|
|
||||||
AvatarURL id.ContentURI
|
|
||||||
AvatarSet bool
|
|
||||||
Encrypted bool
|
|
||||||
LastSync time.Time
|
|
||||||
|
|
||||||
IsParent bool
|
|
||||||
ParentGroup types.JID
|
|
||||||
InSpace bool
|
|
||||||
|
|
||||||
FirstEventID id.EventID
|
|
||||||
NextBatchID id.BatchID
|
|
||||||
RelayUserID id.UserID
|
|
||||||
ExpirationTime uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
|
|
||||||
var mxid, avatarURL, firstEventID, nextBatchID, relayUserID, parentGroupJID sql.NullString
|
|
||||||
var lastSyncTs int64
|
|
||||||
err := row.Scan(
|
|
||||||
&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.NameSet,
|
|
||||||
&portal.Topic, &portal.TopicSet, &portal.Avatar, &avatarURL, &portal.AvatarSet, &portal.Encrypted,
|
|
||||||
&lastSyncTs, &portal.IsParent, &parentGroupJID, &portal.InSpace,
|
|
||||||
&firstEventID, &nextBatchID, &relayUserID, &portal.ExpirationTime,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if lastSyncTs > 0 {
|
|
||||||
portal.LastSync = time.Unix(lastSyncTs, 0)
|
|
||||||
}
|
|
||||||
portal.MXID = id.RoomID(mxid.String)
|
|
||||||
portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
|
|
||||||
if parentGroupJID.Valid {
|
|
||||||
portal.ParentGroup, _ = types.ParseJID(parentGroupJID.String)
|
|
||||||
}
|
|
||||||
portal.FirstEventID = id.EventID(firstEventID.String)
|
|
||||||
portal.NextBatchID = id.BatchID(nextBatchID.String)
|
|
||||||
portal.RelayUserID = id.UserID(relayUserID.String)
|
|
||||||
return portal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sqlVariables() []any {
|
|
||||||
var lastSyncTS int64
|
|
||||||
if !portal.LastSync.IsZero() {
|
|
||||||
lastSyncTS = portal.LastSync.Unix()
|
|
||||||
}
|
|
||||||
return []any{
|
|
||||||
portal.Key.JID, portal.Key.Receiver, dbutil.StrPtr(portal.MXID), portal.Name, portal.NameSet,
|
|
||||||
portal.Topic, portal.TopicSet, portal.Avatar, portal.AvatarURL.String(), portal.AvatarSet, portal.Encrypted,
|
|
||||||
lastSyncTS, portal.IsParent, dbutil.StrPtr(portal.ParentGroup.String()), portal.InSpace,
|
|
||||||
portal.FirstEventID.String(), portal.NextBatchID.String(), dbutil.StrPtr(portal.RelayUserID), portal.ExpirationTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) Insert(ctx context.Context) error {
|
|
||||||
return portal.qh.Exec(ctx, insertPortalQuery, portal.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) Update(ctx context.Context) error {
|
|
||||||
return portal.qh.Exec(ctx, updatePortalQuery, portal.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) Delete(ctx context.Context) error {
|
|
||||||
return portal.qh.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
|
|
||||||
err := portal.qh.Exec(ctx, clearPortalInSpaceQuery, portal.Key.JID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return portal.qh.Exec(ctx, deletePortalQuery, portal.Key.JID, portal.Key.Receiver)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PuppetQuery struct {
|
|
||||||
*dbutil.QueryHelper[*Puppet]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet {
|
|
||||||
return &Puppet{
|
|
||||||
qh: qh,
|
|
||||||
|
|
||||||
EnablePresence: true,
|
|
||||||
EnableReceipts: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllPuppetsQuery = `
|
|
||||||
SELECT username, avatar, avatar_url, displayname, name_quality, name_set, avatar_set, contact_info_set,
|
|
||||||
last_sync, custom_mxid, access_token, next_batch, enable_presence, enable_receipts
|
|
||||||
FROM puppet
|
|
||||||
`
|
|
||||||
getPuppetByJIDQuery = getAllPuppetsQuery + " WHERE username=$1"
|
|
||||||
getPuppetByCustomMXIDQuery = getAllPuppetsQuery + " WHERE custom_mxid=$1"
|
|
||||||
getAllPuppetsWithCustomMXIDQuery = getAllPuppetsQuery + " WHERE custom_mxid<>''"
|
|
||||||
insertPuppetQuery = `
|
|
||||||
INSERT INTO puppet (username, avatar, avatar_url, avatar_set, displayname, name_quality, name_set, contact_info_set,
|
|
||||||
last_sync, custom_mxid, access_token, next_batch, enable_presence, enable_receipts)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
`
|
|
||||||
updatePuppetQuery = `
|
|
||||||
UPDATE puppet
|
|
||||||
SET avatar=$2, avatar_url=$3, avatar_set=$4, displayname=$5, name_quality=$6, name_set=$7, contact_info_set=$8,
|
|
||||||
last_sync=$9, custom_mxid=$10, access_token=$11, next_batch=$12, enable_presence=$13, enable_receipts=$14
|
|
||||||
WHERE username=$1
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (pq *PuppetQuery) GetAll(ctx context.Context) ([]*Puppet, error) {
|
|
||||||
return pq.QueryMany(ctx, getAllPuppetsQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PuppetQuery) Get(ctx context.Context, jid types.JID) (*Puppet, error) {
|
|
||||||
return pq.QueryOne(ctx, getPuppetByJIDQuery, jid.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PuppetQuery) GetByCustomMXID(ctx context.Context, mxid id.UserID) (*Puppet, error) {
|
|
||||||
return pq.QueryOne(ctx, getPuppetByCustomMXIDQuery, mxid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, error) {
|
|
||||||
return pq.QueryMany(ctx, getAllPuppetsWithCustomMXIDQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Puppet struct {
|
|
||||||
qh *dbutil.QueryHelper[*Puppet]
|
|
||||||
|
|
||||||
JID types.JID
|
|
||||||
Avatar string
|
|
||||||
AvatarURL id.ContentURI
|
|
||||||
AvatarSet bool
|
|
||||||
Displayname string
|
|
||||||
NameQuality int8
|
|
||||||
NameSet bool
|
|
||||||
ContactInfoSet bool
|
|
||||||
LastSync time.Time
|
|
||||||
|
|
||||||
CustomMXID id.UserID
|
|
||||||
AccessToken string
|
|
||||||
NextBatch string
|
|
||||||
EnablePresence bool
|
|
||||||
EnableReceipts bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
|
|
||||||
var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString
|
|
||||||
var quality, lastSync sql.NullInt64
|
|
||||||
var enablePresence, enableReceipts, nameSet, avatarSet, contactInfoSet sql.NullBool
|
|
||||||
var username string
|
|
||||||
err := row.Scan(&username, &avatar, &avatarURL, &displayname, &quality, &nameSet, &avatarSet, &contactInfoSet, &lastSync, &customMXID, &accessToken, &nextBatch, &enablePresence, &enableReceipts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
puppet.JID = types.NewJID(username, types.DefaultUserServer)
|
|
||||||
puppet.Displayname = displayname.String
|
|
||||||
puppet.Avatar = avatar.String
|
|
||||||
puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
|
|
||||||
puppet.NameQuality = int8(quality.Int64)
|
|
||||||
puppet.NameSet = nameSet.Bool
|
|
||||||
puppet.AvatarSet = avatarSet.Bool
|
|
||||||
puppet.ContactInfoSet = contactInfoSet.Bool
|
|
||||||
if lastSync.Int64 > 0 {
|
|
||||||
puppet.LastSync = time.Unix(lastSync.Int64, 0)
|
|
||||||
}
|
|
||||||
puppet.CustomMXID = id.UserID(customMXID.String)
|
|
||||||
puppet.AccessToken = accessToken.String
|
|
||||||
puppet.NextBatch = nextBatch.String
|
|
||||||
puppet.EnablePresence = enablePresence.Bool
|
|
||||||
puppet.EnableReceipts = enableReceipts.Bool
|
|
||||||
return puppet, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) sqlVariables() []any {
|
|
||||||
var lastSyncTS int64
|
|
||||||
if !puppet.LastSync.IsZero() {
|
|
||||||
lastSyncTS = puppet.LastSync.Unix()
|
|
||||||
}
|
|
||||||
return []any{
|
|
||||||
puppet.JID.User, puppet.Avatar, puppet.AvatarURL.String(), puppet.AvatarSet, puppet.Displayname,
|
|
||||||
puppet.NameQuality, puppet.NameSet, puppet.ContactInfoSet, lastSyncTS,
|
|
||||||
puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch,
|
|
||||||
puppet.EnablePresence, puppet.EnableReceipts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) Insert(ctx context.Context) error {
|
|
||||||
if puppet.JID.Server != types.DefaultUserServer {
|
|
||||||
zerolog.Ctx(ctx).Warn().Stringer("jid", puppet.JID).Msg("Not inserting puppet: not a user")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return puppet.qh.Exec(ctx, insertPuppetQuery, puppet.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) Update(ctx context.Context) error {
|
|
||||||
return puppet.qh.Exec(ctx, updatePuppetQuery, puppet.sqlVariables()...)
|
|
||||||
}
|
|
|
@ -1,89 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReactionQuery struct {
|
|
||||||
*dbutil.QueryHelper[*Reaction]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction {
|
|
||||||
return &Reaction{qh: qh}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getReactionByTargetJIDQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction
|
|
||||||
WHERE chat_jid=$1 AND chat_receiver=$2 AND target_jid=$3 AND sender=$4
|
|
||||||
`
|
|
||||||
getReactionByMXIDQuery = `
|
|
||||||
SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction
|
|
||||||
WHERE mxid=$1
|
|
||||||
`
|
|
||||||
upsertReactionQuery = `
|
|
||||||
INSERT INTO reaction (chat_jid, chat_receiver, target_jid, sender, mxid, jid)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (chat_jid, chat_receiver, target_jid, sender)
|
|
||||||
DO UPDATE SET mxid=excluded.mxid, jid=excluded.jid
|
|
||||||
`
|
|
||||||
deleteReactionQuery = `
|
|
||||||
DELETE FROM reaction WHERE chat_jid=$1 AND chat_receiver=$2 AND target_jid=$3 AND sender=$4
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (rq *ReactionQuery) GetByTargetJID(ctx context.Context, chat PortalKey, jid types.MessageID, sender types.JID) (*Reaction, error) {
|
|
||||||
return rq.QueryOne(ctx, getReactionByTargetJIDQuery, chat.JID, chat.Receiver, jid, sender.ToNonAD())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) {
|
|
||||||
return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Reaction struct {
|
|
||||||
qh *dbutil.QueryHelper[*Reaction]
|
|
||||||
|
|
||||||
Chat PortalKey
|
|
||||||
TargetJID types.MessageID
|
|
||||||
Sender types.JID
|
|
||||||
MXID id.EventID
|
|
||||||
JID types.MessageID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reaction *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) {
|
|
||||||
return dbutil.ValueOrErr(reaction, row.Scan(&reaction.Chat.JID, &reaction.Chat.Receiver, &reaction.TargetJID, &reaction.Sender, &reaction.MXID, &reaction.JID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reaction *Reaction) sqlVariables() []any {
|
|
||||||
reaction.Sender = reaction.Sender.ToNonAD()
|
|
||||||
return []any{reaction.Chat.JID, reaction.Chat.Receiver, reaction.TargetJID, reaction.Sender, reaction.MXID, reaction.JID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reaction *Reaction) Upsert(ctx context.Context) error {
|
|
||||||
return reaction.qh.Exec(ctx, upsertReactionQuery, reaction.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reaction *Reaction) Delete(ctx context.Context) error {
|
|
||||||
return reaction.qh.Exec(ctx, deleteReactionQuery, reaction.Chat.JID, reaction.Chat.Receiver, reaction.TargetJID, reaction.Sender)
|
|
||||||
}
|
|
|
@ -1,208 +0,0 @@
|
||||||
-- v0 -> v57 (compatible with v45+): Latest revision
|
|
||||||
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
mxid TEXT PRIMARY KEY,
|
|
||||||
username TEXT UNIQUE,
|
|
||||||
agent SMALLINT,
|
|
||||||
device SMALLINT,
|
|
||||||
|
|
||||||
management_room TEXT,
|
|
||||||
space_room TEXT,
|
|
||||||
|
|
||||||
phone_last_seen BIGINT,
|
|
||||||
phone_last_pinged BIGINT,
|
|
||||||
|
|
||||||
timezone TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE portal (
|
|
||||||
jid TEXT,
|
|
||||||
receiver TEXT,
|
|
||||||
mxid TEXT UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
topic_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
avatar TEXT NOT NULL,
|
|
||||||
avatar_url TEXT,
|
|
||||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
encrypted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
last_sync BIGINT NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
is_parent BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
parent_group TEXT,
|
|
||||||
in_space BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
first_event_id TEXT,
|
|
||||||
next_batch_id TEXT,
|
|
||||||
relay_user_id TEXT,
|
|
||||||
expiration_time BIGINT NOT NULL DEFAULT 0 CHECK (expiration_time >= 0 AND expiration_time < 4294967296),
|
|
||||||
|
|
||||||
PRIMARY KEY (jid, receiver)
|
|
||||||
);
|
|
||||||
CREATE INDEX portal_parent_group_idx ON portal(parent_group);
|
|
||||||
|
|
||||||
CREATE TABLE puppet (
|
|
||||||
username TEXT PRIMARY KEY,
|
|
||||||
displayname TEXT,
|
|
||||||
name_quality SMALLINT,
|
|
||||||
avatar TEXT,
|
|
||||||
avatar_url TEXT,
|
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
last_sync BIGINT NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
custom_mxid TEXT,
|
|
||||||
access_token TEXT,
|
|
||||||
next_batch TEXT,
|
|
||||||
|
|
||||||
enable_presence BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
enable_receipts BOOLEAN NOT NULL DEFAULT true
|
|
||||||
);
|
|
||||||
|
|
||||||
-- only: postgres
|
|
||||||
CREATE TYPE error_type AS ENUM ('', 'decryption_failed', 'media_not_found');
|
|
||||||
|
|
||||||
CREATE TABLE message (
|
|
||||||
chat_jid TEXT,
|
|
||||||
chat_receiver TEXT,
|
|
||||||
jid TEXT,
|
|
||||||
mxid TEXT UNIQUE,
|
|
||||||
sender TEXT,
|
|
||||||
sender_mxid TEXT NOT NULL DEFAULT '',
|
|
||||||
timestamp BIGINT,
|
|
||||||
sent BOOLEAN,
|
|
||||||
error error_type,
|
|
||||||
type TEXT,
|
|
||||||
|
|
||||||
broadcast_list_jid TEXT,
|
|
||||||
|
|
||||||
PRIMARY KEY (chat_jid, chat_receiver, jid),
|
|
||||||
FOREIGN KEY (chat_jid, chat_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX message_timestamp_idx ON message (chat_jid, chat_receiver, timestamp);
|
|
||||||
|
|
||||||
CREATE TABLE poll_option_id (
|
|
||||||
msg_mxid TEXT,
|
|
||||||
opt_id TEXT,
|
|
||||||
opt_hash bytea CHECK ( length(opt_hash) = 32 ),
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, opt_id),
|
|
||||||
CONSTRAINT poll_option_unique_hash UNIQUE (msg_mxid, opt_hash),
|
|
||||||
CONSTRAINT message_mxid_fkey FOREIGN KEY (msg_mxid) REFERENCES message(mxid) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE reaction (
|
|
||||||
chat_jid TEXT,
|
|
||||||
chat_receiver TEXT,
|
|
||||||
target_jid TEXT,
|
|
||||||
sender TEXT,
|
|
||||||
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
jid TEXT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (chat_jid, chat_receiver, target_jid, sender),
|
|
||||||
FOREIGN KEY (chat_jid, chat_receiver, target_jid) REFERENCES message(chat_jid, chat_receiver, jid)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE disappearing_message (
|
|
||||||
room_id TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
expire_in BIGINT NOT NULL,
|
|
||||||
expire_at BIGINT,
|
|
||||||
PRIMARY KEY (room_id, event_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_portal (
|
|
||||||
user_mxid TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
last_read_ts BIGINT NOT NULL DEFAULT 0,
|
|
||||||
in_space BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE backfill_queue (
|
|
||||||
queue_id INTEGER PRIMARY KEY
|
|
||||||
-- only: postgres
|
|
||||||
GENERATED ALWAYS AS IDENTITY
|
|
||||||
,
|
|
||||||
user_mxid TEXT,
|
|
||||||
type INTEGER NOT NULL,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
time_start TIMESTAMP,
|
|
||||||
dispatch_time TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
batch_delay INTEGER,
|
|
||||||
max_batch_events INTEGER NOT NULL,
|
|
||||||
max_total_events INTEGER,
|
|
||||||
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE backfill_state (
|
|
||||||
user_mxid TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
processing_batch BOOLEAN,
|
|
||||||
backfill_complete BOOLEAN,
|
|
||||||
first_expected_ts BIGINT,
|
|
||||||
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE media_backfill_requests (
|
|
||||||
user_mxid TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
media_key bytea,
|
|
||||||
status INTEGER,
|
|
||||||
error TEXT,
|
|
||||||
PRIMARY KEY (user_mxid, portal_jid, portal_receiver, event_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE history_sync_conversation (
|
|
||||||
user_mxid TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
|
|
||||||
last_message_timestamp TIMESTAMP,
|
|
||||||
archived BOOLEAN,
|
|
||||||
pinned INTEGER,
|
|
||||||
mute_end_time TIMESTAMP,
|
|
||||||
disappearing_mode INTEGER,
|
|
||||||
end_of_history_transfer_type INTEGER,
|
|
||||||
ephemeral_Expiration INTEGER,
|
|
||||||
marked_as_unread BOOLEAN,
|
|
||||||
unread_count INTEGER,
|
|
||||||
|
|
||||||
PRIMARY KEY (user_mxid, conversation_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE history_sync_message (
|
|
||||||
user_mxid TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
message_id TEXT,
|
|
||||||
timestamp TIMESTAMP,
|
|
||||||
data bytea,
|
|
||||||
inserted_time TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (user_mxid, conversation_id, message_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_mxid, conversation_id) REFERENCES history_sync_conversation(user_mxid, conversation_id) ON DELETE CASCADE
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v36: Store approximate last seen timestamp of the main device
|
|
||||||
|
|
||||||
ALTER TABLE "user" ADD COLUMN phone_last_seen BIGINT;
|
|
|
@ -1,11 +0,0 @@
|
||||||
-- v37: Store message error type as string
|
|
||||||
|
|
||||||
-- only: postgres
|
|
||||||
CREATE TYPE error_type AS ENUM ('', 'decryption_failed', 'media_not_found');
|
|
||||||
|
|
||||||
ALTER TABLE message ADD COLUMN error error_type NOT NULL DEFAULT '';
|
|
||||||
UPDATE message SET error='decryption_failed' WHERE decryption_error=true;
|
|
||||||
|
|
||||||
-- TODO do this on sqlite at some point
|
|
||||||
-- only: postgres
|
|
||||||
ALTER TABLE message DROP COLUMN decryption_error;
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v38: Store timestamp for previous phone ping
|
|
||||||
|
|
||||||
ALTER TABLE "user" ADD COLUMN phone_last_pinged BIGINT;
|
|
|
@ -1,21 +0,0 @@
|
||||||
-- v39: Add support for reactions
|
|
||||||
|
|
||||||
ALTER TABLE message ADD COLUMN type TEXT NOT NULL DEFAULT 'message';
|
|
||||||
-- only: postgres
|
|
||||||
ALTER TABLE message ALTER COLUMN type DROP DEFAULT;
|
|
||||||
|
|
||||||
UPDATE message SET type='' WHERE error='decryption_failed';
|
|
||||||
UPDATE message SET type='fake' WHERE jid LIKE 'FAKE::%' OR mxid LIKE 'net.maunium.whatsapp.fake::%' OR jid=mxid;
|
|
||||||
|
|
||||||
CREATE TABLE reaction (
|
|
||||||
chat_jid TEXT,
|
|
||||||
chat_receiver TEXT,
|
|
||||||
target_jid TEXT,
|
|
||||||
sender TEXT,
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
jid TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (chat_jid, chat_receiver, target_jid, sender),
|
|
||||||
CONSTRAINT target_message_fkey FOREIGN KEY (chat_jid, chat_receiver, target_jid)
|
|
||||||
REFERENCES message (chat_jid, chat_receiver, jid)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
|
@ -1,22 +0,0 @@
|
||||||
-- v40: Add backfill queue
|
|
||||||
|
|
||||||
CREATE TABLE backfill_queue (
|
|
||||||
queue_id INTEGER PRIMARY KEY
|
|
||||||
-- only: postgres
|
|
||||||
GENERATED ALWAYS AS IDENTITY
|
|
||||||
,
|
|
||||||
user_mxid TEXT,
|
|
||||||
type INTEGER NOT NULL,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
time_start TIMESTAMP,
|
|
||||||
time_end TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
batch_delay INTEGER,
|
|
||||||
max_batch_events INTEGER NOT NULL,
|
|
||||||
max_total_events INTEGER,
|
|
||||||
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
|
@ -1,31 +0,0 @@
|
||||||
-- v41: Store history syncs for later backfills
|
|
||||||
|
|
||||||
CREATE TABLE history_sync_conversation (
|
|
||||||
user_mxid TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
last_message_timestamp TIMESTAMP,
|
|
||||||
archived BOOLEAN,
|
|
||||||
pinned INTEGER,
|
|
||||||
mute_end_time TIMESTAMP,
|
|
||||||
disappearing_mode INTEGER,
|
|
||||||
end_of_history_transfer_type INTEGER,
|
|
||||||
ephemeral_expiration INTEGER,
|
|
||||||
marked_as_unread BOOLEAN,
|
|
||||||
unread_count INTEGER,
|
|
||||||
PRIMARY KEY (user_mxid, conversation_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE history_sync_message (
|
|
||||||
user_mxid TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
message_id TEXT,
|
|
||||||
timestamp TIMESTAMP,
|
|
||||||
data BYTEA,
|
|
||||||
PRIMARY KEY (user_mxid, conversation_id, message_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (user_mxid, conversation_id) REFERENCES history_sync_conversation (user_mxid, conversation_id) ON DELETE CASCADE
|
|
||||||
);
|
|
|
@ -1,9 +0,0 @@
|
||||||
-- v42: Update backfill queue tables to be sortable by priority
|
|
||||||
|
|
||||||
UPDATE backfill_queue
|
|
||||||
SET type=CASE
|
|
||||||
WHEN type = 1 THEN 200
|
|
||||||
WHEN type = 2 THEN 300
|
|
||||||
ELSE type
|
|
||||||
END
|
|
||||||
WHERE type = 1 OR type = 2;
|
|
|
@ -1,14 +0,0 @@
|
||||||
-- v43: Add table for tracking which media needs to be requested from the user's phone
|
|
||||||
|
|
||||||
CREATE TABLE media_backfill_requests (
|
|
||||||
user_mxid TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
media_key BYTEA,
|
|
||||||
status INTEGER,
|
|
||||||
error TEXT,
|
|
||||||
PRIMARY KEY (user_mxid, portal_jid, portal_receiver, event_id),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v44: Add timezone column for users
|
|
||||||
|
|
||||||
ALTER TABLE "user" ADD COLUMN timezone TEXT;
|
|
|
@ -1,5 +0,0 @@
|
||||||
-- v45: Add dispatch time to backfill queue
|
|
||||||
|
|
||||||
ALTER TABLE backfill_queue ADD COLUMN dispatch_time TIMESTAMP;
|
|
||||||
UPDATE backfill_queue SET dispatch_time=completed_at;
|
|
||||||
ALTER TABLE backfill_queue DROP COLUMN time_end;
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v46: Add inserted time to history sync message
|
|
||||||
|
|
||||||
ALTER TABLE history_sync_message ADD COLUMN inserted_time TIMESTAMP;
|
|
|
@ -1,13 +0,0 @@
|
||||||
-- v47: Add table for keeping track of backfill state
|
|
||||||
|
|
||||||
CREATE TABLE backfill_state (
|
|
||||||
user_mxid TEXT,
|
|
||||||
portal_jid TEXT,
|
|
||||||
portal_receiver TEXT,
|
|
||||||
processing_batch BOOLEAN,
|
|
||||||
backfill_complete BOOLEAN,
|
|
||||||
first_expected_ts INTEGER,
|
|
||||||
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
|
|
||||||
);
|
|
|
@ -1,7 +0,0 @@
|
||||||
-- v48: Move crypto/state/whatsmeow store upgrade handling to separate systems
|
|
||||||
CREATE TABLE crypto_version (version INTEGER PRIMARY KEY);
|
|
||||||
INSERT INTO crypto_version VALUES (6);
|
|
||||||
CREATE TABLE whatsmeow_version (version INTEGER PRIMARY KEY);
|
|
||||||
INSERT INTO whatsmeow_version VALUES (1);
|
|
||||||
CREATE TABLE mx_version (version INTEGER PRIMARY KEY);
|
|
||||||
INSERT INTO mx_version VALUES (1);
|
|
|
@ -1,13 +0,0 @@
|
||||||
-- v49: Convert first_expected_ts to BIGINT
|
|
||||||
-- only: postgres
|
|
||||||
|
|
||||||
DO
|
|
||||||
$do$
|
|
||||||
BEGIN
|
|
||||||
IF (SELECT data_type FROM information_schema.columns WHERE table_name='backfill_state' AND column_name='first_expected_ts') = 'integer' THEN
|
|
||||||
ALTER TABLE backfill_state ALTER COLUMN first_expected_ts TYPE BIGINT;
|
|
||||||
ELSE
|
|
||||||
ALTER TABLE backfill_state ALTER COLUMN first_expected_ts TYPE BIGINT USING EXTRACT(EPOCH FROM first_expected_ts);
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$do$
|
|
|
@ -1,13 +0,0 @@
|
||||||
-- v50: Add last sync timestamp for puppets
|
|
||||||
|
|
||||||
ALTER TABLE puppet ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
UPDATE puppet SET name_set=true WHERE displayname<>'';
|
|
||||||
UPDATE puppet SET avatar_set=true WHERE avatar<>'';
|
|
||||||
UPDATE portal SET name_set=true WHERE name<>'';
|
|
||||||
UPDATE portal SET avatar_set=true WHERE avatar<>'';
|
|
||||||
UPDATE portal SET topic_set=true WHERE topic<>'';
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v51: Add last sync timestamp for portals too
|
|
||||||
|
|
||||||
ALTER TABLE portal ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0;
|
|
|
@ -1,5 +0,0 @@
|
||||||
-- v52: Store portal metadata for communities
|
|
||||||
|
|
||||||
ALTER TABLE portal ADD COLUMN is_parent BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE portal ADD COLUMN parent_group TEXT;
|
|
||||||
ALTER TABLE portal ADD COLUMN in_space BOOLEAN NOT NULL DEFAULT false;
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- v53: Add index to make querying by community faster
|
|
||||||
CREATE INDEX portal_parent_group_idx ON portal(parent_group);
|
|
|
@ -1,11 +0,0 @@
|
||||||
-- v54: Store mapping for poll option IDs from Matrix
|
|
||||||
|
|
||||||
CREATE TABLE poll_option_id (
|
|
||||||
msg_mxid TEXT,
|
|
||||||
opt_id TEXT,
|
|
||||||
opt_hash bytea CHECK ( length(opt_hash) = 32 ),
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, opt_id),
|
|
||||||
CONSTRAINT poll_option_unique_hash UNIQUE (msg_mxid, opt_hash),
|
|
||||||
CONSTRAINT message_mxid_fkey FOREIGN KEY (msg_mxid) REFERENCES message(mxid) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- v55: Store whether custom contact info has been set for a puppet
|
|
||||||
|
|
||||||
ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- v56 (compatible with v45+): Store whether custom contact info has been set for a puppet
|
|
||||||
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- v57 (compatible with v45+): Add index for message timestamp to make read receipt handling faster
|
|
||||||
CREATE INDEX message_timestamp_idx ON message (chat_jid, chat_receiver, timestamp);
|
|
|
@ -1,37 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package upgrades
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"embed"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Table dbutil.UpgradeTable
|
|
||||||
|
|
||||||
//go:embed *.sql
|
|
||||||
var rawUpgrades embed.FS
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Table.Register(-1, 35, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error {
|
|
||||||
return errors.New("please upgrade to mautrix-whatsapp v0.4.0 before upgrading to a newer version")
|
|
||||||
})
|
|
||||||
Table.RegisterFS(rawUpgrades)
|
|
||||||
}
|
|
146
database/user.go
146
database/user.go
|
@ -1,146 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserQuery struct {
|
|
||||||
*dbutil.QueryHelper[*User]
|
|
||||||
}
|
|
||||||
|
|
||||||
func newUser(qh *dbutil.QueryHelper[*User]) *User {
|
|
||||||
return &User{
|
|
||||||
qh: qh,
|
|
||||||
|
|
||||||
lastReadCache: make(map[PortalKey]time.Time),
|
|
||||||
inSpaceCache: make(map[PortalKey]bool),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
getAllUsersQuery = `SELECT mxid, username, agent, device, management_room, space_room, phone_last_seen, phone_last_pinged, timezone FROM "user"`
|
|
||||||
getUserByMXIDQuery = getAllUsersQuery + ` WHERE mxid=$1`
|
|
||||||
getUserByUsernameQuery = getAllUsersQuery + ` WHERE username=$1`
|
|
||||||
insertUserQuery = `
|
|
||||||
INSERT INTO "user" (
|
|
||||||
mxid, username, agent, device,
|
|
||||||
management_room, space_room,
|
|
||||||
phone_last_seen, phone_last_pinged, timezone
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
`
|
|
||||||
updateUserQuery = `
|
|
||||||
UPDATE "user"
|
|
||||||
SET username=$2, agent=$3, device=$4,
|
|
||||||
management_room=$5, space_room=$6,
|
|
||||||
phone_last_seen=$7, phone_last_pinged=$8, timezone=$9
|
|
||||||
WHERE mxid=$1
|
|
||||||
`
|
|
||||||
getUserLastAppStateKeyIDQuery = "SELECT key_id FROM whatsmeow_app_state_sync_keys WHERE jid=$1 ORDER BY timestamp DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (uq *UserQuery) GetAll(ctx context.Context) ([]*User, error) {
|
|
||||||
return uq.QueryMany(ctx, getAllUsersQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) {
|
|
||||||
return uq.QueryOne(ctx, getUserByMXIDQuery, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uq *UserQuery) GetByUsername(ctx context.Context, username string) (*User, error) {
|
|
||||||
return uq.QueryOne(ctx, getUserByUsernameQuery, username)
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
qh *dbutil.QueryHelper[*User]
|
|
||||||
|
|
||||||
MXID id.UserID
|
|
||||||
JID types.JID
|
|
||||||
ManagementRoom id.RoomID
|
|
||||||
SpaceRoom id.RoomID
|
|
||||||
PhoneLastSeen time.Time
|
|
||||||
PhoneLastPinged time.Time
|
|
||||||
Timezone string
|
|
||||||
|
|
||||||
lastReadCache map[PortalKey]time.Time
|
|
||||||
lastReadCacheLock sync.Mutex
|
|
||||||
inSpaceCache map[PortalKey]bool
|
|
||||||
inSpaceCacheLock sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) Scan(row dbutil.Scannable) (*User, error) {
|
|
||||||
var username, timezone sql.NullString
|
|
||||||
var device, agent sql.NullInt16
|
|
||||||
var phoneLastSeen, phoneLastPinged sql.NullInt64
|
|
||||||
err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom, &user.SpaceRoom, &phoneLastSeen, &phoneLastPinged, &timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
user.Timezone = timezone.String
|
|
||||||
if len(username.String) > 0 {
|
|
||||||
user.JID = types.JID{
|
|
||||||
User: username.String,
|
|
||||||
Device: uint16(device.Int16),
|
|
||||||
Server: types.DefaultUserServer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if phoneLastSeen.Valid {
|
|
||||||
user.PhoneLastSeen = time.Unix(phoneLastSeen.Int64, 0)
|
|
||||||
}
|
|
||||||
if phoneLastPinged.Valid {
|
|
||||||
user.PhoneLastPinged = time.Unix(phoneLastPinged.Int64, 0)
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) sqlVariables() []any {
|
|
||||||
var username *string
|
|
||||||
var agent, device *uint16
|
|
||||||
if !user.JID.IsEmpty() {
|
|
||||||
username = dbutil.StrPtr(user.JID.User)
|
|
||||||
var zero uint16
|
|
||||||
agent = &zero
|
|
||||||
device = dbutil.NumPtr(user.JID.Device)
|
|
||||||
}
|
|
||||||
return []any{
|
|
||||||
user.MXID, username, agent, device, user.ManagementRoom, user.SpaceRoom,
|
|
||||||
dbutil.UnixPtr(user.PhoneLastSeen), dbutil.UnixPtr(user.PhoneLastPinged),
|
|
||||||
user.Timezone,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) Insert(ctx context.Context) error {
|
|
||||||
return user.qh.Exec(ctx, insertUserQuery, user.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) Update(ctx context.Context) error {
|
|
||||||
return user.qh.Exec(ctx, updateUserQuery, user.sqlVariables()...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) GetLastAppStateKeyID(ctx context.Context) (keyID []byte, err error) {
|
|
||||||
err = user.qh.GetDB().QueryRow(ctx, getUserLastAppStateKeyIDQuery, user.JID).Scan(&keyID)
|
|
||||||
return
|
|
||||||
}
|
|
|
@ -1,114 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
getLastReadTSQuery = "SELECT last_read_ts FROM user_portal WHERE user_mxid=$1 AND portal_jid=$2 AND portal_receiver=$3"
|
|
||||||
setLastReadTSQuery = `
|
|
||||||
INSERT INTO user_portal (user_mxid, portal_jid, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (user_mxid, portal_jid, portal_receiver) DO UPDATE SET last_read_ts=excluded.last_read_ts WHERE user_portal.last_read_ts<excluded.last_read_ts
|
|
||||||
`
|
|
||||||
getIsInSpaceQuery = "SELECT in_space FROM user_portal WHERE user_mxid=$1 AND portal_jid=$2 AND portal_receiver=$3"
|
|
||||||
setIsInSpaceQuery = `
|
|
||||||
INSERT INTO user_portal (user_mxid, portal_jid, portal_receiver, in_space) VALUES ($1, $2, $3, true)
|
|
||||||
ON CONFLICT (user_mxid, portal_jid, portal_receiver) DO UPDATE SET in_space=true
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
func (user *User) GetLastReadTS(ctx context.Context, portal PortalKey) time.Time {
|
|
||||||
user.lastReadCacheLock.Lock()
|
|
||||||
defer user.lastReadCacheLock.Unlock()
|
|
||||||
if cached, ok := user.lastReadCache[portal]; ok {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
var ts int64
|
|
||||||
var parsedTS time.Time
|
|
||||||
err := user.qh.GetDB().QueryRow(ctx, getLastReadTSQuery, user.MXID, portal.JID, portal.Receiver).Scan(&ts)
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Str("user_id", user.MXID.String()).
|
|
||||||
Any("portal_key", portal).
|
|
||||||
Msg("Failed to query last read timestamp")
|
|
||||||
return parsedTS
|
|
||||||
}
|
|
||||||
if ts != 0 {
|
|
||||||
parsedTS = time.Unix(ts, 0)
|
|
||||||
}
|
|
||||||
user.lastReadCache[portal] = parsedTS
|
|
||||||
return user.lastReadCache[portal]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) SetLastReadTS(ctx context.Context, portal PortalKey, ts time.Time) {
|
|
||||||
user.lastReadCacheLock.Lock()
|
|
||||||
defer user.lastReadCacheLock.Unlock()
|
|
||||||
_, err := user.qh.GetDB().Exec(ctx, setLastReadTSQuery, user.MXID, portal.JID, portal.Receiver, ts.Unix())
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Str("user_id", user.MXID.String()).
|
|
||||||
Any("portal_key", portal).
|
|
||||||
Msg("Failed to update last read timestamp")
|
|
||||||
} else {
|
|
||||||
zerolog.Ctx(ctx).Debug().
|
|
||||||
Str("user_id", user.MXID.String()).
|
|
||||||
Any("portal_key", portal).
|
|
||||||
Time("last_read_ts", ts).
|
|
||||||
Msg("Updated last read timestamp of portal")
|
|
||||||
user.lastReadCache[portal] = ts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) IsInSpace(ctx context.Context, portal PortalKey) bool {
|
|
||||||
user.inSpaceCacheLock.Lock()
|
|
||||||
defer user.inSpaceCacheLock.Unlock()
|
|
||||||
if cached, ok := user.inSpaceCache[portal]; ok {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
var inSpace bool
|
|
||||||
err := user.qh.GetDB().QueryRow(ctx, getIsInSpaceQuery, user.MXID, portal.JID, portal.Receiver).Scan(&inSpace)
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Str("user_id", user.MXID.String()).
|
|
||||||
Any("portal_key", portal).
|
|
||||||
Msg("Failed to query in space status")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
user.inSpaceCache[portal] = inSpace
|
|
||||||
return inSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) MarkInSpace(ctx context.Context, portal PortalKey) {
|
|
||||||
user.inSpaceCacheLock.Lock()
|
|
||||||
defer user.inSpaceCacheLock.Unlock()
|
|
||||||
_, err := user.qh.GetDB().Exec(ctx, setIsInSpaceQuery, user.MXID, portal.JID, portal.Receiver)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Str("user_id", user.MXID.String()).
|
|
||||||
Any("portal_key", portal).
|
|
||||||
Msg("Failed to update in space status")
|
|
||||||
} else {
|
|
||||||
user.inSpaceCache[portal] = true
|
|
||||||
}
|
|
||||||
}
|
|
102
disappear.go
102
disappear.go
|
@ -1,102 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (portal *Portal) MarkDisappearing(ctx context.Context, eventID id.EventID, expiresIn time.Duration, startsAt time.Time) {
|
|
||||||
if expiresIn == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expiresAt := startsAt.Add(expiresIn)
|
|
||||||
|
|
||||||
msg := portal.bridge.DB.DisappearingMessage.NewWithValues(portal.MXID, eventID, expiresIn, expiresAt)
|
|
||||||
err := msg.Insert(ctx)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to insert disappearing message")
|
|
||||||
}
|
|
||||||
if expiresAt.Before(time.Now().Add(1 * time.Hour)) {
|
|
||||||
go portal.sleepAndDelete(context.WithoutCancel(ctx), msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) SleepAndDeleteUpcoming(ctx context.Context) {
|
|
||||||
msgs, err := br.DB.DisappearingMessage.GetUpcomingScheduled(ctx, 1*time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to get upcoming disappearing messages")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, msg := range msgs {
|
|
||||||
portal := br.GetPortalByMXID(msg.RoomID)
|
|
||||||
if portal == nil {
|
|
||||||
err = msg.Delete(ctx)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Stringer("event_id", msg.EventID).
|
|
||||||
Msg("Failed to delete disappearing message row with no portal")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
go portal.sleepAndDelete(ctx, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sleepAndDelete(ctx context.Context, msg *database.DisappearingMessage) {
|
|
||||||
if _, alreadySleeping := portal.currentlySleepingToDelete.LoadOrStore(msg.EventID, true); alreadySleeping {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer portal.currentlySleepingToDelete.Delete(msg.EventID)
|
|
||||||
log := zerolog.Ctx(ctx)
|
|
||||||
|
|
||||||
sleepTime := msg.ExpireAt.Sub(time.Now())
|
|
||||||
log.Debug().
|
|
||||||
Stringer("room_id", portal.MXID).
|
|
||||||
Stringer("event_id", msg.EventID).
|
|
||||||
Dur("sleep_time", sleepTime).
|
|
||||||
Msg("Sleeping before making message disappear")
|
|
||||||
time.Sleep(sleepTime)
|
|
||||||
_, err := portal.MainIntent().RedactEvent(ctx, msg.RoomID, msg.EventID, mautrix.ReqRedact{
|
|
||||||
Reason: "Message expired",
|
|
||||||
TxnID: fmt.Sprintf("mxwa_disappear_%s", msg.EventID),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).
|
|
||||||
Stringer("room_id", portal.MXID).
|
|
||||||
Stringer("event_id", msg.EventID).
|
|
||||||
Msg("Failed to make event disappear")
|
|
||||||
} else {
|
|
||||||
log.Debug().
|
|
||||||
Stringer("room_id", portal.MXID).
|
|
||||||
Stringer("event_id", msg.EventID).
|
|
||||||
Msg("Disappeared event")
|
|
||||||
}
|
|
||||||
err = msg.Delete(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to delete disapperaing message row in database after redacting event")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,11 +15,7 @@ function fixperms {
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ ! -f /data/config.yaml ]]; then
|
if [[ ! -f /data/config.yaml ]]; then
|
||||||
if [[ "$BRIDGEV2" == "1" ]]; then
|
/usr/bin/mautrix-whatsapp -c /data/config.yaml -e
|
||||||
/usr/bin/mautrix-whatsapp -c /data/config.yaml -e
|
|
||||||
else
|
|
||||||
cp /opt/mautrix-whatsapp/example-config.yaml /data/config.yaml
|
|
||||||
fi
|
|
||||||
echo "Didn't find a config file."
|
echo "Didn't find a config file."
|
||||||
echo "Copied default config file to /data/config.yaml"
|
echo "Copied default config file to /data/config.yaml"
|
||||||
echo "Modify that config file to your liking."
|
echo "Modify that config file to your liking."
|
||||||
|
|
|
@ -1,481 +0,0 @@
|
||||||
# Homeserver details.
|
|
||||||
homeserver:
|
|
||||||
# The address that this appservice can use to connect to the homeserver.
|
|
||||||
address: https://matrix.example.com
|
|
||||||
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
|
|
||||||
domain: example.com
|
|
||||||
|
|
||||||
# What software is the homeserver running?
|
|
||||||
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
|
|
||||||
software: standard
|
|
||||||
# The URL to push real-time bridge status to.
|
|
||||||
# If set, the bridge will make POST requests to this URL whenever a user's whatsapp connection state changes.
|
|
||||||
# The bridge will use the appservice as_token to authorize requests.
|
|
||||||
status_endpoint: null
|
|
||||||
# Endpoint for reporting per-message status.
|
|
||||||
message_send_checkpoint_endpoint: null
|
|
||||||
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
|
|
||||||
async_media: false
|
|
||||||
|
|
||||||
# Should the bridge use a websocket for connecting to the homeserver?
|
|
||||||
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
|
|
||||||
# mautrix-asmux (deprecated), and hungryserv (proprietary).
|
|
||||||
websocket: false
|
|
||||||
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
|
|
||||||
ping_interval_seconds: 0
|
|
||||||
|
|
||||||
# Application service host/registration related details.
|
|
||||||
# Changing these values requires regeneration of the registration.
|
|
||||||
appservice:
|
|
||||||
# The address that the homeserver can use to connect to this appservice.
|
|
||||||
address: http://localhost:29318
|
|
||||||
|
|
||||||
# The hostname and port where this appservice should listen.
|
|
||||||
hostname: 0.0.0.0
|
|
||||||
port: 29318
|
|
||||||
|
|
||||||
# Database config.
|
|
||||||
database:
|
|
||||||
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
|
|
||||||
type: postgres
|
|
||||||
# The database URI.
|
|
||||||
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
|
|
||||||
# https://github.com/mattn/go-sqlite3#connection-string
|
|
||||||
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
|
|
||||||
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
|
|
||||||
uri: postgres://user:password@host/database?sslmode=disable
|
|
||||||
# Maximum number of connections. Mostly relevant for Postgres.
|
|
||||||
max_open_conns: 20
|
|
||||||
max_idle_conns: 2
|
|
||||||
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
|
|
||||||
# Parsed with https://pkg.go.dev/time#ParseDuration
|
|
||||||
max_conn_idle_time: null
|
|
||||||
max_conn_lifetime: null
|
|
||||||
|
|
||||||
# The unique ID of this appservice.
|
|
||||||
id: whatsapp
|
|
||||||
# Appservice bot details.
|
|
||||||
bot:
|
|
||||||
# Username of the appservice bot.
|
|
||||||
username: whatsappbot
|
|
||||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
|
||||||
# to leave display name/avatar as-is.
|
|
||||||
displayname: WhatsApp bridge bot
|
|
||||||
avatar: mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr
|
|
||||||
|
|
||||||
# Whether or not to receive ephemeral events via appservice transactions.
|
|
||||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
|
||||||
ephemeral_events: true
|
|
||||||
|
|
||||||
# Should incoming events be handled asynchronously?
|
|
||||||
# This may be necessary for large public instances with lots of messages going through.
|
|
||||||
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
|
|
||||||
async_transactions: false
|
|
||||||
|
|
||||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
|
||||||
as_token: "This value is generated when generating the registration"
|
|
||||||
hs_token: "This value is generated when generating the registration"
|
|
||||||
|
|
||||||
# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors.
|
|
||||||
analytics:
|
|
||||||
# Hostname of the tracking server. The path is hardcoded to /v1/track
|
|
||||||
host: api.segment.io
|
|
||||||
# API key to send with tracking requests. Tracking is disabled if this is null.
|
|
||||||
token: null
|
|
||||||
# Optional user ID for tracking events. If null, defaults to using Matrix user ID.
|
|
||||||
user_id: null
|
|
||||||
|
|
||||||
# Prometheus config.
|
|
||||||
metrics:
|
|
||||||
# Enable prometheus metrics?
|
|
||||||
enabled: false
|
|
||||||
# IP and port where the metrics listener should be. The path is always /metrics
|
|
||||||
listen: 127.0.0.1:8001
|
|
||||||
|
|
||||||
# Config for things that are directly sent to WhatsApp.
|
|
||||||
whatsapp:
|
|
||||||
# Device name that's shown in the "WhatsApp Web" section in the mobile app.
|
|
||||||
os_name: Mautrix-WhatsApp bridge
|
|
||||||
# Browser name that determines the logo shown in the mobile app.
|
|
||||||
# Must be "unknown" for a generic icon or a valid browser name if you want a specific icon.
|
|
||||||
# List of valid browser names: https://github.com/tulir/whatsmeow/blob/efc632c008604016ddde63bfcfca8de4e5304da9/binary/proto/def.proto#L43-L64
|
|
||||||
browser_name: unknown
|
|
||||||
# Proxy to use for all WhatsApp connections.
|
|
||||||
proxy: null
|
|
||||||
# Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for WhatsApp connections.
|
|
||||||
get_proxy_url: null
|
|
||||||
# Whether the proxy options should only apply to the login websocket and not to authenticated connections.
|
|
||||||
proxy_only_login: false
|
|
||||||
|
|
||||||
# Bridge config
|
|
||||||
bridge:
|
|
||||||
# Localpart template of MXIDs for WhatsApp users.
|
|
||||||
# {{.}} is replaced with the phone number of the WhatsApp user.
|
|
||||||
username_template: whatsapp_{{.}}
|
|
||||||
# Displayname template for WhatsApp users.
|
|
||||||
# {{.PushName}} - nickname set by the WhatsApp user
|
|
||||||
# {{.BusinessName}} - validated WhatsApp business name
|
|
||||||
# {{.Phone}} - phone number (international format)
|
|
||||||
# The following variables are also available, but will cause problems on multi-user instances:
|
|
||||||
# {{.FullName}} - full name from contact list
|
|
||||||
# {{.FirstName}} - first name from contact list
|
|
||||||
displayname_template: "{{or .BusinessName .PushName .JID}} (WA)"
|
|
||||||
# Should the bridge create a space for each logged-in user and add bridged rooms to it?
|
|
||||||
# Users who logged in before turning this on should run `!wa sync space` to create and fill the space for the first time.
|
|
||||||
personal_filtering_spaces: false
|
|
||||||
# Should the bridge send a read receipt from the bridge bot when a message has been sent to WhatsApp?
|
|
||||||
delivery_receipts: false
|
|
||||||
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
|
||||||
message_status_events: false
|
|
||||||
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
|
|
||||||
message_error_notices: true
|
|
||||||
# Should incoming calls send a message to the Matrix room?
|
|
||||||
call_start_notices: true
|
|
||||||
# Should another user's cryptographic identity changing send a message to Matrix?
|
|
||||||
identity_change_notices: false
|
|
||||||
portal_message_buffer: 128
|
|
||||||
# Settings for handling history sync payloads.
|
|
||||||
history_sync:
|
|
||||||
# Enable backfilling history sync payloads from WhatsApp?
|
|
||||||
backfill: true
|
|
||||||
# The maximum number of initial conversations that should be synced.
|
|
||||||
# Other conversations will be backfilled on demand when receiving a message or when initiating a direct chat.
|
|
||||||
max_initial_conversations: -1
|
|
||||||
# Maximum number of messages to backfill in each conversation.
|
|
||||||
# Set to -1 to disable limit.
|
|
||||||
message_count: 50
|
|
||||||
# Should the bridge request a full sync from the phone when logging in?
|
|
||||||
# This bumps the size of history syncs from 3 months to 1 year.
|
|
||||||
request_full_sync: false
|
|
||||||
# Configuration parameters that are sent to the phone along with the request full sync flag.
|
|
||||||
# By default (when the values are null or 0), the config isn't sent at all.
|
|
||||||
full_sync_config:
|
|
||||||
# Number of days of history to request.
|
|
||||||
# The limit seems to be around 3 years, but using higher values doesn't break.
|
|
||||||
days_limit: null
|
|
||||||
# This is presumably the maximum size of the transferred history sync blob, which may affect what the phone includes in the blob.
|
|
||||||
size_mb_limit: null
|
|
||||||
# This is presumably the local storage quota, which may affect what the phone includes in the history sync blob.
|
|
||||||
storage_quota_mb: null
|
|
||||||
# If this value is greater than 0, then if the conversation's last message was more than
|
|
||||||
# this number of hours ago, then the conversation will automatically be marked it as read.
|
|
||||||
# Conversations that have a last message that is less than this number of hours ago will
|
|
||||||
# have their unread status synced from WhatsApp.
|
|
||||||
unread_hours_threshold: 0
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# The settings below are only applicable for backfilling using batch sending, #
|
|
||||||
# which is no longer supported in Synapse. #
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Settings for media requests. If the media expired, then it will not be on the WA servers.
|
|
||||||
# Media can always be requested by reacting with the ♻️ (recycle) emoji.
|
|
||||||
# These settings determine if the media requests should be done automatically during or after backfill.
|
|
||||||
media_requests:
|
|
||||||
# Should expired media be automatically requested from the server as part of the backfill process?
|
|
||||||
auto_request_media: true
|
|
||||||
# Whether to request the media immediately after the media message is backfilled ("immediate")
|
|
||||||
# or at a specific time of the day ("local_time").
|
|
||||||
request_method: immediate
|
|
||||||
# If request_method is "local_time", what time should the requests be sent (in minutes after midnight)?
|
|
||||||
request_local_time: 120
|
|
||||||
# Maximum number of media request responses to handle in parallel per user.
|
|
||||||
max_async_handle: 2
|
|
||||||
# Settings for immediate backfills. These backfills should generally be small and their main purpose is
|
|
||||||
# to populate each of the initial chats (as configured by max_initial_conversations) with a few messages
|
|
||||||
# so that you can continue conversations without losing context.
|
|
||||||
immediate:
|
|
||||||
# The number of concurrent backfill workers to create for immediate backfills.
|
|
||||||
# Note that using more than one worker could cause the room list to jump around
|
|
||||||
# since there are no guarantees about the order in which the backfills will complete.
|
|
||||||
worker_count: 1
|
|
||||||
# The maximum number of events to backfill initially.
|
|
||||||
max_events: 10
|
|
||||||
# Settings for deferred backfills. The purpose of these backfills are to fill in the rest of
|
|
||||||
# the chat history that was not covered by the immediate backfills.
|
|
||||||
# These backfills generally should happen at a slower pace so as not to overload the homeserver.
|
|
||||||
# Each deferred backfill config should define a "stage" of backfill (i.e. the last week of messages).
|
|
||||||
# The fields are as follows:
|
|
||||||
# - start_days_ago: the number of days ago to start backfilling from.
|
|
||||||
# To indicate the start of time, use -1. For example, for a week ago, use 7.
|
|
||||||
# - max_batch_events: the number of events to send per batch.
|
|
||||||
# - batch_delay: the number of seconds to wait before backfilling each batch.
|
|
||||||
deferred:
|
|
||||||
# Last Week
|
|
||||||
- start_days_ago: 7
|
|
||||||
max_batch_events: 20
|
|
||||||
batch_delay: 5
|
|
||||||
# Last Month
|
|
||||||
- start_days_ago: 30
|
|
||||||
max_batch_events: 50
|
|
||||||
batch_delay: 10
|
|
||||||
# Last 3 months
|
|
||||||
- start_days_ago: 90
|
|
||||||
max_batch_events: 100
|
|
||||||
batch_delay: 10
|
|
||||||
# The start of time
|
|
||||||
- start_days_ago: -1
|
|
||||||
max_batch_events: 500
|
|
||||||
batch_delay: 10
|
|
||||||
|
|
||||||
# Should puppet avatars be fetched from the server even if an avatar is already set?
|
|
||||||
user_avatar_sync: true
|
|
||||||
# Should Matrix users leaving groups be bridged to WhatsApp?
|
|
||||||
bridge_matrix_leave: true
|
|
||||||
# Should the bridge update the m.direct account data event when double puppeting is enabled.
|
|
||||||
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
|
|
||||||
# and is therefore prone to race conditions.
|
|
||||||
sync_direct_chat_list: false
|
|
||||||
# Should the bridge use MSC2867 to bridge manual "mark as unread"s from
|
|
||||||
# WhatsApp and set the unread status on initial backfill?
|
|
||||||
# This will only work on clients that support the m.marked_unread or
|
|
||||||
# com.famedly.marked_unread room account data.
|
|
||||||
sync_manual_marked_unread: true
|
|
||||||
# When double puppeting is enabled, users can use `!wa toggle` to change whether
|
|
||||||
# presence is bridged. This setting sets the default value.
|
|
||||||
# Existing users won't be affected when these are changed.
|
|
||||||
default_bridge_presence: true
|
|
||||||
# Send the presence as "available" to whatsapp when users start typing on a portal.
|
|
||||||
# This works as a workaround for homeservers that do not support presence, and allows
|
|
||||||
# users to see when the whatsapp user on the other side is typing during a conversation.
|
|
||||||
send_presence_on_typing: false
|
|
||||||
# Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp)
|
|
||||||
# even if the user isn't marked as online (e.g. when presence bridging isn't enabled)?
|
|
||||||
#
|
|
||||||
# By default, the bridge acts like WhatsApp web, which only sends active delivery
|
|
||||||
# receipts when it's in the foreground.
|
|
||||||
force_active_delivery_receipts: false
|
|
||||||
# Servers to always allow double puppeting from
|
|
||||||
double_puppet_server_map:
|
|
||||||
example.com: https://example.com
|
|
||||||
# Allow using double puppeting from any server with a valid client .well-known file.
|
|
||||||
double_puppet_allow_discovery: false
|
|
||||||
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
|
|
||||||
#
|
|
||||||
# If set, double puppeting will be enabled automatically for local users
|
|
||||||
# instead of users having to find an access token and run `login-matrix`
|
|
||||||
# manually.
|
|
||||||
login_shared_secret_map:
|
|
||||||
example.com: foobar
|
|
||||||
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
|
||||||
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
|
||||||
# If set to `always`, all DM rooms will have explicit names and avatars set.
|
|
||||||
# If set to `never`, DM rooms will never have names and avatars set.
|
|
||||||
private_chat_portal_meta: default
|
|
||||||
# Should group members be synced in parallel? This makes member sync faster
|
|
||||||
parallel_member_sync: false
|
|
||||||
# Should Matrix m.notice-type messages be bridged?
|
|
||||||
bridge_notices: true
|
|
||||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
|
||||||
# This field will automatically be changed back to false after it, except if the config file is not writable.
|
|
||||||
resend_bridge_info: false
|
|
||||||
# When using double puppeting, should muted chats be muted in Matrix?
|
|
||||||
mute_bridging: false
|
|
||||||
# When using double puppeting, should archived chats be moved to a specific tag in Matrix?
|
|
||||||
# Note that WhatsApp unarchives chats when a message is received, which will also be mirrored to Matrix.
|
|
||||||
# This can be set to a tag (e.g. m.lowpriority), or null to disable.
|
|
||||||
archive_tag: null
|
|
||||||
# Same as above, but for pinned chats. The favorite tag is called m.favourite
|
|
||||||
pinned_tag: null
|
|
||||||
# Should mute status and tags only be bridged when the portal room is created?
|
|
||||||
tag_only_on_create: true
|
|
||||||
# Should WhatsApp status messages be bridged into a Matrix room?
|
|
||||||
# Disabling this won't affect already created status broadcast rooms.
|
|
||||||
enable_status_broadcast: true
|
|
||||||
# Should sending WhatsApp status messages be allowed?
|
|
||||||
# This can cause issues if the user has lots of contacts, so it's disabled by default.
|
|
||||||
disable_status_broadcast_send: true
|
|
||||||
# Should the status broadcast room be muted and moved into low priority by default?
|
|
||||||
# This is only applied when creating the room, the user can unmute it later.
|
|
||||||
mute_status_broadcast: true
|
|
||||||
# Tag to apply to the status broadcast room.
|
|
||||||
status_broadcast_tag: m.lowpriority
|
|
||||||
# Should the bridge use thumbnails from WhatsApp?
|
|
||||||
# They're disabled by default due to very low resolution.
|
|
||||||
whatsapp_thumbnail: false
|
|
||||||
# Allow invite permission for user. User can invite any bots to room with whatsapp
|
|
||||||
# users (private chat and groups)
|
|
||||||
allow_user_invite: false
|
|
||||||
# Whether or not created rooms should have federation enabled.
|
|
||||||
# If false, created portal rooms will never be federated.
|
|
||||||
federate_rooms: true
|
|
||||||
# Should the bridge never send alerts to the bridge management room?
|
|
||||||
# These are mostly things like the user being logged out.
|
|
||||||
disable_bridge_alerts: false
|
|
||||||
# Should the bridge stop if the WhatsApp server says another user connected with the same session?
|
|
||||||
# This is only safe on single-user bridges.
|
|
||||||
crash_on_stream_replaced: false
|
|
||||||
# Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview,
|
|
||||||
# and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews`
|
|
||||||
# key in the event content even if this is disabled.
|
|
||||||
url_previews: false
|
|
||||||
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
|
|
||||||
# This is currently not supported in most clients.
|
|
||||||
caption_in_message: false
|
|
||||||
# Send galleries as a single event? This is not an MSC (yet).
|
|
||||||
beeper_galleries: false
|
|
||||||
# Should polls be sent using MSC3381 event types?
|
|
||||||
extev_polls: false
|
|
||||||
# Should cross-chat replies from WhatsApp be bridged? Most servers and clients don't support this.
|
|
||||||
cross_room_replies: false
|
|
||||||
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
|
|
||||||
# but they're being phased out and will be completely removed in the future.
|
|
||||||
disable_reply_fallbacks: false
|
|
||||||
# Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration
|
|
||||||
# Null means there's no enforced timeout.
|
|
||||||
message_handling_timeout:
|
|
||||||
# Send an error message after this timeout, but keep waiting for the response until the deadline.
|
|
||||||
# This is counted from the origin_server_ts, so the warning time is consistent regardless of the source of delay.
|
|
||||||
# If the message is older than this when it reaches the bridge, the message won't be handled at all.
|
|
||||||
error_after: null
|
|
||||||
# Drop messages after this timeout. They may still go through if the message got sent to the servers.
|
|
||||||
# This is counted from the time the bridge starts handling the message.
|
|
||||||
deadline: 120s
|
|
||||||
|
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
|
||||||
command_prefix: "!wa"
|
|
||||||
|
|
||||||
# Messages sent upon joining a management room.
|
|
||||||
# Markdown is supported. The defaults are listed below.
|
|
||||||
management_room_text:
|
|
||||||
# Sent when joining a room.
|
|
||||||
welcome: "Hello, I'm a WhatsApp bridge bot."
|
|
||||||
# Sent when joining a management room and the user is already logged in.
|
|
||||||
welcome_connected: "Use `help` for help."
|
|
||||||
# Sent when joining a management room and the user is not logged in.
|
|
||||||
welcome_unconnected: "Use `help` for help or `login` to log in."
|
|
||||||
# Optional extra text sent when joining a management room.
|
|
||||||
additional_help: ""
|
|
||||||
|
|
||||||
# End-to-bridge encryption support options.
|
|
||||||
#
|
|
||||||
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
|
||||||
encryption:
|
|
||||||
# Allow encryption, work in group chat rooms with e2ee enabled
|
|
||||||
allow: false
|
|
||||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
|
||||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
|
||||||
default: false
|
|
||||||
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
|
|
||||||
appservice: false
|
|
||||||
# Require encryption, drop any unencrypted messages.
|
|
||||||
require: false
|
|
||||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
|
||||||
# You must use a client that supports requesting keys from other users to use this feature.
|
|
||||||
allow_key_sharing: false
|
|
||||||
# Should users mentions be in the event wire content to enable the server to send push notifications?
|
|
||||||
plaintext_mentions: false
|
|
||||||
# Options for deleting megolm sessions from the bridge.
|
|
||||||
delete_keys:
|
|
||||||
# Beeper-specific: delete outbound sessions when hungryserv confirms
|
|
||||||
# that the user has uploaded the key to key backup.
|
|
||||||
delete_outbound_on_ack: false
|
|
||||||
# Don't store outbound sessions in the inbound table.
|
|
||||||
dont_store_outbound: false
|
|
||||||
# Ratchet megolm sessions forward after decrypting messages.
|
|
||||||
ratchet_on_decrypt: false
|
|
||||||
# Delete fully used keys (index >= max_messages) after decrypting messages.
|
|
||||||
delete_fully_used_on_decrypt: false
|
|
||||||
# Delete previous megolm sessions from same device when receiving a new one.
|
|
||||||
delete_prev_on_new_session: false
|
|
||||||
# Delete megolm sessions received from a device when the device is deleted.
|
|
||||||
delete_on_device_delete: false
|
|
||||||
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
|
|
||||||
periodically_delete_expired: false
|
|
||||||
# Delete inbound megolm sessions that don't have the received_at field used for
|
|
||||||
# automatic ratcheting and expired session deletion. This is meant as a migration
|
|
||||||
# to delete old keys prior to the bridge update.
|
|
||||||
delete_outdated_inbound: false
|
|
||||||
# What level of device verification should be required from users?
|
|
||||||
#
|
|
||||||
# Valid levels:
|
|
||||||
# unverified - Send keys to all device in the room.
|
|
||||||
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
|
|
||||||
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
|
|
||||||
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
|
|
||||||
# Note that creating user signatures from the bridge bot is not currently possible.
|
|
||||||
# verified - Require manual per-device verification
|
|
||||||
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
|
|
||||||
verification_levels:
|
|
||||||
# Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
|
|
||||||
receive: unverified
|
|
||||||
# Minimum level that the bridge should accept for incoming Matrix messages.
|
|
||||||
send: unverified
|
|
||||||
# Minimum level that the bridge should require for accepting key requests.
|
|
||||||
share: cross-signed-tofu
|
|
||||||
# Options for Megolm room key rotation. These options allow you to
|
|
||||||
# configure the m.room.encryption event content. See:
|
|
||||||
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
|
|
||||||
# more information about that event.
|
|
||||||
rotation:
|
|
||||||
# Enable custom Megolm room key rotation settings. Note that these
|
|
||||||
# settings will only apply to rooms created after this option is
|
|
||||||
# set.
|
|
||||||
enable_custom: false
|
|
||||||
# The maximum number of milliseconds a session should be used
|
|
||||||
# before changing it. The Matrix spec recommends 604800000 (a week)
|
|
||||||
# as the default.
|
|
||||||
milliseconds: 604800000
|
|
||||||
# The maximum number of messages that should be sent with a given a
|
|
||||||
# session before changing it. The Matrix spec recommends 100 as the
|
|
||||||
# default.
|
|
||||||
messages: 100
|
|
||||||
|
|
||||||
# Disable rotating keys when a user's devices change?
|
|
||||||
# You should not enable this option unless you understand all the implications.
|
|
||||||
disable_device_change_key_rotation: false
|
|
||||||
|
|
||||||
# Settings for provisioning API
|
|
||||||
provisioning:
|
|
||||||
# Prefix for the provisioning API paths.
|
|
||||||
prefix: /_matrix/provision
|
|
||||||
# Shared secret for authentication. If set to "generate", a random secret will be generated,
|
|
||||||
# or if set to "disable", the provisioning API will be disabled.
|
|
||||||
shared_secret: generate
|
|
||||||
# Enable debug API at /debug with provisioning authentication.
|
|
||||||
debug_endpoints: false
|
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
|
||||||
# Permitted values:
|
|
||||||
# relay - Talk through the relaybot (if enabled), no access otherwise
|
|
||||||
# user - Access to use the bridge to chat with a WhatsApp account.
|
|
||||||
# admin - User level and some additional administration tools
|
|
||||||
# Permitted keys:
|
|
||||||
# * - All Matrix users
|
|
||||||
# domain - All users on that homeserver
|
|
||||||
# mxid - Specific user
|
|
||||||
permissions:
|
|
||||||
"*": relay
|
|
||||||
"example.com": user
|
|
||||||
"@admin:example.com": admin
|
|
||||||
|
|
||||||
# Settings for relay mode
|
|
||||||
relay:
|
|
||||||
# Whether relay mode should be allowed. If allowed, `!wa set-relay` can be used to turn any
|
|
||||||
# authenticated user into a relaybot for that chat.
|
|
||||||
enabled: false
|
|
||||||
# Should only admins be allowed to set themselves as relay users?
|
|
||||||
admin_only: true
|
|
||||||
# The formats to use when sending messages to WhatsApp via the relaybot.
|
|
||||||
message_formats:
|
|
||||||
m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
|
|
||||||
m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
|
|
||||||
m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
|
|
||||||
m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
|
|
||||||
m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
|
|
||||||
m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
|
|
||||||
m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
|
|
||||||
m.location: "<b>{{ .Sender.Displayname }}</b> sent a location"
|
|
||||||
|
|
||||||
# Logging config. See https://github.com/tulir/zeroconfig for details.
|
|
||||||
logging:
|
|
||||||
min_level: debug
|
|
||||||
writers:
|
|
||||||
- type: stdout
|
|
||||||
format: pretty-colored
|
|
||||||
- type: file
|
|
||||||
format: json
|
|
||||||
filename: ./logs/mautrix-whatsapp.log
|
|
||||||
max_size: 100
|
|
||||||
max_backups: 10
|
|
||||||
compress: true
|
|
208
formatting.go
208
formatting.go
|
@ -1,208 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2023 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/format"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
|
|
||||||
var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
|
|
||||||
var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
|
|
||||||
var codeBlockRegex = regexp.MustCompile("```(?:.|\n)+?```")
|
|
||||||
var inlineURLRegex = regexp.MustCompile(`\[(.+?)]\((.+?)\)`)
|
|
||||||
|
|
||||||
const mentionedJIDsContextKey = "fi.mau.whatsapp.mentioned_jids"
|
|
||||||
const allowedMentionsContextKey = "fi.mau.whatsapp.allowed_mentions"
|
|
||||||
|
|
||||||
type Formatter struct {
|
|
||||||
bridge *WABridge
|
|
||||||
|
|
||||||
matrixHTMLParser *format.HTMLParser
|
|
||||||
|
|
||||||
waReplString map[*regexp.Regexp]string
|
|
||||||
waReplFunc map[*regexp.Regexp]func(string) string
|
|
||||||
waReplFuncText map[*regexp.Regexp]func(string) string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFormatter(bridge *WABridge) *Formatter {
|
|
||||||
formatter := &Formatter{
|
|
||||||
bridge: bridge,
|
|
||||||
matrixHTMLParser: &format.HTMLParser{
|
|
||||||
TabsToSpaces: 4,
|
|
||||||
Newline: "\n",
|
|
||||||
|
|
||||||
PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string {
|
|
||||||
allowedMentions, _ := ctx.ReturnData[allowedMentionsContextKey].(map[types.JID]bool)
|
|
||||||
if mxid[0] == '@' {
|
|
||||||
var jid types.JID
|
|
||||||
if puppet := bridge.GetPuppetByMXID(id.UserID(mxid)); puppet != nil {
|
|
||||||
jid = puppet.JID
|
|
||||||
} else if user := bridge.GetUserByMXIDIfExists(id.UserID(mxid)); user != nil {
|
|
||||||
jid = user.JID.ToNonAD()
|
|
||||||
}
|
|
||||||
if !jid.IsEmpty() && (allowedMentions == nil || allowedMentions[jid]) {
|
|
||||||
if allowedMentions == nil {
|
|
||||||
jids, ok := ctx.ReturnData[mentionedJIDsContextKey].([]string)
|
|
||||||
if !ok {
|
|
||||||
ctx.ReturnData[mentionedJIDsContextKey] = []string{jid.String()}
|
|
||||||
} else {
|
|
||||||
ctx.ReturnData[mentionedJIDsContextKey] = append(jids, jid.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "@" + jid.User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return displayname
|
|
||||||
},
|
|
||||||
BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) },
|
|
||||||
ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) },
|
|
||||||
StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) },
|
|
||||||
MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
|
|
||||||
MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
|
|
||||||
},
|
|
||||||
waReplString: map[*regexp.Regexp]string{
|
|
||||||
italicRegex: "$1<em>$2</em>$3",
|
|
||||||
boldRegex: "$1<strong>$2</strong>$3",
|
|
||||||
strikethroughRegex: "$1<del>$2</del>$3",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
formatter.waReplFunc = map[*regexp.Regexp]func(string) string{
|
|
||||||
codeBlockRegex: func(str string) string {
|
|
||||||
str = str[3 : len(str)-3]
|
|
||||||
if strings.ContainsRune(str, '\n') {
|
|
||||||
return fmt.Sprintf("<pre><code>%s</code></pre>", str)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("<code>%s</code>", str)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{}
|
|
||||||
return formatter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (formatter *Formatter) getMatrixInfoByJID(ctx context.Context, roomID id.RoomID, jid types.JID) (mxid id.UserID, displayname string) {
|
|
||||||
if puppet := formatter.bridge.GetPuppetByJID(jid); puppet != nil {
|
|
||||||
mxid = puppet.MXID
|
|
||||||
displayname = puppet.Displayname
|
|
||||||
}
|
|
||||||
if user := formatter.bridge.GetUserByJID(jid); user != nil {
|
|
||||||
mxid = user.MXID
|
|
||||||
member, err := formatter.bridge.StateStore.GetMember(ctx, roomID, user.MXID)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).
|
|
||||||
Stringer("room_id", roomID).
|
|
||||||
Stringer("user_id", user.MXID).
|
|
||||||
Msg("Failed to get member profile from state store")
|
|
||||||
} else if len(member.Displayname) > 0 {
|
|
||||||
displayname = member.Displayname
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (formatter *Formatter) ParseWhatsApp(ctx context.Context, roomID id.RoomID, content *event.MessageEventContent, mentionedJIDs []string, allowInlineURL, forceHTML bool) {
|
|
||||||
output := html.EscapeString(content.Body)
|
|
||||||
for regex, replacement := range formatter.waReplString {
|
|
||||||
output = regex.ReplaceAllString(output, replacement)
|
|
||||||
}
|
|
||||||
for regex, replacer := range formatter.waReplFunc {
|
|
||||||
output = regex.ReplaceAllStringFunc(output, replacer)
|
|
||||||
}
|
|
||||||
if allowInlineURL {
|
|
||||||
output = inlineURLRegex.ReplaceAllStringFunc(output, func(s string) string {
|
|
||||||
groups := inlineURLRegex.FindStringSubmatch(s)
|
|
||||||
return fmt.Sprintf(`<a href="%s">%s</a>`, groups[2], groups[1])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alreadyMentioned := make(map[id.UserID]struct{})
|
|
||||||
content.Mentions = &event.Mentions{}
|
|
||||||
for _, rawJID := range mentionedJIDs {
|
|
||||||
jid, err := types.ParseJID(rawJID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
} else if jid.Server == types.LegacyUserServer {
|
|
||||||
jid.Server = types.DefaultUserServer
|
|
||||||
} else if jid.Server != types.DefaultUserServer {
|
|
||||||
// TODO lid support?
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mxid, displayname := formatter.getMatrixInfoByJID(ctx, roomID, jid)
|
|
||||||
number := "@" + jid.User
|
|
||||||
output = strings.ReplaceAll(output, number, fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname))
|
|
||||||
content.Body = strings.ReplaceAll(content.Body, number, displayname)
|
|
||||||
if _, ok := alreadyMentioned[mxid]; !ok {
|
|
||||||
alreadyMentioned[mxid] = struct{}{}
|
|
||||||
content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if output != content.Body || forceHTML {
|
|
||||||
output = strings.ReplaceAll(output, "\n", "<br/>")
|
|
||||||
content.FormattedBody = output
|
|
||||||
content.Format = event.FormatHTML
|
|
||||||
for regex, replacer := range formatter.waReplFuncText {
|
|
||||||
content.Body = regex.ReplaceAllStringFunc(content.Body, replacer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (formatter *Formatter) ParseMatrix(html string, mentions *event.Mentions) (string, []string) {
|
|
||||||
ctx := format.NewContext(context.TODO())
|
|
||||||
var mentionedJIDs []string
|
|
||||||
if mentions != nil {
|
|
||||||
var allowedMentions = make(map[types.JID]bool)
|
|
||||||
mentionedJIDs = make([]string, 0, len(mentions.UserIDs))
|
|
||||||
for _, userID := range mentions.UserIDs {
|
|
||||||
var jid types.JID
|
|
||||||
if puppet := formatter.bridge.GetPuppetByMXID(userID); puppet != nil {
|
|
||||||
jid = puppet.JID
|
|
||||||
mentionedJIDs = append(mentionedJIDs, puppet.JID.String())
|
|
||||||
} else if user := formatter.bridge.GetUserByMXIDIfExists(userID); user != nil {
|
|
||||||
jid = user.JID.ToNonAD()
|
|
||||||
}
|
|
||||||
if !jid.IsEmpty() && !allowedMentions[jid] {
|
|
||||||
allowedMentions[jid] = true
|
|
||||||
mentionedJIDs = append(mentionedJIDs, jid.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.ReturnData[allowedMentionsContextKey] = allowedMentions
|
|
||||||
}
|
|
||||||
result := formatter.matrixHTMLParser.Parse(html, ctx)
|
|
||||||
if mentions == nil {
|
|
||||||
mentionedJIDs, _ = ctx.ReturnData[mentionedJIDsContextKey].([]string)
|
|
||||||
sort.Strings(mentionedJIDs)
|
|
||||||
mentionedJIDs = slices.Compact(mentionedJIDs)
|
|
||||||
}
|
|
||||||
return result, mentionedJIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (formatter *Formatter) ParseMatrixWithoutMentions(html string) string {
|
|
||||||
ctx := format.NewContext(context.TODO())
|
|
||||||
ctx.ReturnData[allowedMentionsContextKey] = map[types.JID]struct{}{}
|
|
||||||
return formatter.matrixHTMLParser.Parse(html, ctx)
|
|
||||||
}
|
|
20
go.mod
20
go.mod
|
@ -8,18 +8,13 @@ require (
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.23
|
|
||||||
github.com/prometheus/client_golang v1.20.3
|
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/rs/zerolog v1.33.0
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
|
||||||
github.com/tidwall/gjson v1.17.3
|
|
||||||
go.mau.fi/util v0.8.1-0.20240925093630-1734c3c342eb
|
go.mau.fi/util v0.8.1-0.20240925093630-1734c3c342eb
|
||||||
go.mau.fi/webp v0.1.0
|
go.mau.fi/webp v0.1.0
|
||||||
go.mau.fi/whatsmeow v0.0.0-20240927134544-69ba055bef0f
|
go.mau.fi/whatsmeow v0.0.0-20240927134544-69ba055bef0f
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
||||||
golang.org/x/image v0.20.0
|
golang.org/x/image v0.20.0
|
||||||
golang.org/x/net v0.29.0
|
golang.org/x/net v0.29.0
|
||||||
golang.org/x/sync v0.8.0
|
|
||||||
google.golang.org/protobuf v1.34.2
|
google.golang.org/protobuf v1.34.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.21.1-0.20240927113633-d1e5b09d972b
|
maunium.net/go/mautrix v0.21.1-0.20240927113633-d1e5b09d972b
|
||||||
|
@ -27,20 +22,17 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||||
github.com/prometheus/common v0.55.0 // indirect
|
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
github.com/tidwall/gjson v1.17.3 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
@ -48,8 +40,10 @@ require (
|
||||||
go.mau.fi/libsignal v0.1.1 // indirect
|
go.mau.fi/libsignal v0.1.1 // indirect
|
||||||
go.mau.fi/zeroconfig v0.1.3 // indirect
|
go.mau.fi/zeroconfig v0.1.3 // indirect
|
||||||
golang.org/x/crypto v0.27.0 // indirect
|
golang.org/x/crypto v0.27.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/text v0.18.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
maunium.net/go/mauflag v1.0.0 // indirect
|
maunium.net/go/mauflag v1.0.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
23
go.sum
23
go.sum
|
@ -2,10 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
@ -20,14 +16,13 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
@ -38,21 +33,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
|
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
|
||||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
|
||||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
|
1024
historysync.go
1024
historysync.go
File diff suppressed because it is too large
Load diff
279
main.go
279
main.go
|
@ -1,279 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
_ "embed"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/whatsmeow/proto/waCompanionReg"
|
|
||||||
waLog "go.mau.fi/whatsmeow/util/log"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow"
|
|
||||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
|
||||||
"go.mau.fi/whatsmeow/store"
|
|
||||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
|
|
||||||
"go.mau.fi/util/configupgrade"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/bridge"
|
|
||||||
"maunium.net/go/mautrix/bridge/commands"
|
|
||||||
"maunium.net/go/mautrix/bridge/status"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/config"
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Information to find out exactly which commit the bridge was built from.
|
|
||||||
// These are filled at build time with the -X linker flag.
|
|
||||||
var (
|
|
||||||
Tag = "unknown"
|
|
||||||
Commit = "unknown"
|
|
||||||
BuildTime = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed example-config.yaml
|
|
||||||
var ExampleConfig string
|
|
||||||
|
|
||||||
type WABridge struct {
|
|
||||||
bridge.Bridge
|
|
||||||
Config *config.Config
|
|
||||||
DB *database.Database
|
|
||||||
Provisioning *ProvisioningAPI
|
|
||||||
Formatter *Formatter
|
|
||||||
Metrics *MetricsHandler
|
|
||||||
WAContainer *sqlstore.Container
|
|
||||||
WAVersion string
|
|
||||||
|
|
||||||
usersByMXID map[id.UserID]*User
|
|
||||||
usersByUsername map[string]*User
|
|
||||||
usersLock sync.Mutex
|
|
||||||
spaceRooms map[id.RoomID]*User
|
|
||||||
spaceRoomsLock sync.Mutex
|
|
||||||
managementRooms map[id.RoomID]*User
|
|
||||||
managementRoomsLock sync.Mutex
|
|
||||||
portalsByMXID map[id.RoomID]*Portal
|
|
||||||
portalsByJID map[database.PortalKey]*Portal
|
|
||||||
portalsLock sync.Mutex
|
|
||||||
puppets map[types.JID]*Puppet
|
|
||||||
puppetsByCustomMXID map[id.UserID]*Puppet
|
|
||||||
puppetsLock sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) Init() {
|
|
||||||
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
|
|
||||||
br.RegisterCommands()
|
|
||||||
|
|
||||||
// TODO this is a weird place for this
|
|
||||||
br.EventProcessor.On(event.EphemeralEventPresence, br.HandlePresence)
|
|
||||||
br.EventProcessor.On(TypeMSC3381PollStart, br.MatrixHandler.HandleMessage)
|
|
||||||
br.EventProcessor.On(TypeMSC3381PollResponse, br.MatrixHandler.HandleMessage)
|
|
||||||
br.EventProcessor.On(TypeMSC3381V2PollResponse, br.MatrixHandler.HandleMessage)
|
|
||||||
|
|
||||||
Analytics.log = br.ZLog.With().Str("component", "analytics").Logger()
|
|
||||||
Analytics.url = (&url.URL{
|
|
||||||
Scheme: "https",
|
|
||||||
Host: br.Config.Analytics.Host,
|
|
||||||
Path: "/v1/track",
|
|
||||||
}).String()
|
|
||||||
Analytics.key = br.Config.Analytics.Token
|
|
||||||
Analytics.userID = br.Config.Analytics.UserID
|
|
||||||
if Analytics.IsEnabled() {
|
|
||||||
Analytics.log.Info().Str("override_user_id", Analytics.userID).Msg("Analytics metrics are enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
br.DB = database.New(br.Bridge.DB)
|
|
||||||
br.WAContainer = sqlstore.NewWithDB(br.DB.RawDB, br.DB.Dialect.String(), waLog.Zerolog(br.ZLog.With().Str("db_section", "whatsmeow").Logger()))
|
|
||||||
br.WAContainer.DatabaseErrorHandler = br.DB.HandleSignalStoreError
|
|
||||||
|
|
||||||
ss := br.Config.Bridge.Provisioning.SharedSecret
|
|
||||||
if len(ss) > 0 && ss != "disable" {
|
|
||||||
br.Provisioning = &ProvisioningAPI{bridge: br, log: br.ZLog.With().Str("component", "provisioning").Logger()}
|
|
||||||
}
|
|
||||||
|
|
||||||
br.Formatter = NewFormatter(br)
|
|
||||||
br.Metrics = NewMetricsHandler(br.Config.Metrics.Listen, br.ZLog.With().Str("component", "metrics").Logger(), br.DB)
|
|
||||||
br.MatrixHandler.TrackEventDuration = br.Metrics.TrackMatrixEvent
|
|
||||||
|
|
||||||
store.BaseClientPayload.UserAgent.OsVersion = proto.String(br.WAVersion)
|
|
||||||
store.BaseClientPayload.UserAgent.OsBuildNumber = proto.String(br.WAVersion)
|
|
||||||
store.DeviceProps.Os = proto.String(br.Config.WhatsApp.OSName)
|
|
||||||
store.DeviceProps.RequireFullSync = proto.Bool(br.Config.Bridge.HistorySync.RequestFullSync)
|
|
||||||
if fsc := br.Config.Bridge.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
|
|
||||||
store.DeviceProps.HistorySyncConfig = &waProto.DeviceProps_HistorySyncConfig{
|
|
||||||
FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
|
|
||||||
FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
|
|
||||||
StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
versionParts := strings.Split(br.WAVersion, ".")
|
|
||||||
if len(versionParts) > 2 {
|
|
||||||
primary, _ := strconv.Atoi(versionParts[0])
|
|
||||||
secondary, _ := strconv.Atoi(versionParts[1])
|
|
||||||
tertiary, _ := strconv.Atoi(versionParts[2])
|
|
||||||
store.DeviceProps.Version.Primary = proto.Uint32(uint32(primary))
|
|
||||||
store.DeviceProps.Version.Secondary = proto.Uint32(uint32(secondary))
|
|
||||||
store.DeviceProps.Version.Tertiary = proto.Uint32(uint32(tertiary))
|
|
||||||
}
|
|
||||||
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(br.Config.WhatsApp.BrowserName)]
|
|
||||||
if ok {
|
|
||||||
store.DeviceProps.PlatformType = waProto.DeviceProps_PlatformType(platformID).Enum()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) Start() {
|
|
||||||
err := br.WAContainer.Upgrade()
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade whatsmeow database")
|
|
||||||
os.Exit(15)
|
|
||||||
}
|
|
||||||
if br.Provisioning != nil {
|
|
||||||
br.Provisioning.Init()
|
|
||||||
}
|
|
||||||
// TODO find out how the new whatsapp version checks for updates
|
|
||||||
ver, err := whatsmeow.GetLatestVersion(br.AS.HTTPClient)
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.Err(err).Msg("Failed to get latest WhatsApp web version number")
|
|
||||||
} else {
|
|
||||||
br.ZLog.Debug().
|
|
||||||
Stringer("hardcoded_version", store.GetWAVersion()).
|
|
||||||
Stringer("latest_version", *ver).
|
|
||||||
Msg("Got latest WhatsApp web version number")
|
|
||||||
store.SetWAVersion(*ver)
|
|
||||||
}
|
|
||||||
br.WaitWebsocketConnected()
|
|
||||||
go br.StartUsers()
|
|
||||||
if br.Config.Metrics.Enabled {
|
|
||||||
go br.Metrics.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
go br.Loop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) Loop() {
|
|
||||||
ctx := br.ZLog.With().Str("action", "background loop").Logger().WithContext(context.TODO())
|
|
||||||
for {
|
|
||||||
br.SleepAndDeleteUpcoming(ctx)
|
|
||||||
time.Sleep(1 * time.Hour)
|
|
||||||
br.WarnUsersAboutDisconnection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) WarnUsersAboutDisconnection() {
|
|
||||||
br.usersLock.Lock()
|
|
||||||
for _, user := range br.usersByUsername {
|
|
||||||
if user.IsConnected() && !user.PhoneRecentlySeen(true) {
|
|
||||||
go user.sendPhoneOfflineWarning(context.TODO())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
br.usersLock.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) StartUsers() {
|
|
||||||
br.ZLog.Debug().Msg("Starting users")
|
|
||||||
foundAnySessions := false
|
|
||||||
for _, user := range br.GetAllUsers() {
|
|
||||||
if !user.JID.IsEmpty() {
|
|
||||||
foundAnySessions = true
|
|
||||||
}
|
|
||||||
go user.Connect()
|
|
||||||
}
|
|
||||||
if !foundAnySessions {
|
|
||||||
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
|
|
||||||
}
|
|
||||||
br.ZLog.Debug().Msg("Starting custom puppets")
|
|
||||||
for _, loopuppet := range br.GetAllPuppetsWithCustomMXID() {
|
|
||||||
go func(puppet *Puppet) {
|
|
||||||
puppet.zlog.Debug().Stringer("custom_mxid", puppet.CustomMXID).Msg("Starting double puppet")
|
|
||||||
err := puppet.StartCustomMXID(true)
|
|
||||||
if err != nil {
|
|
||||||
puppet.zlog.Err(err).Stringer("custom_mxid", puppet.CustomMXID).Msg("Failed to start double puppet")
|
|
||||||
}
|
|
||||||
}(loopuppet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) Stop() {
|
|
||||||
br.Metrics.Stop()
|
|
||||||
for _, user := range br.usersByUsername {
|
|
||||||
if user.Client == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
user.zlog.Debug().Msg("Disconnecting user")
|
|
||||||
user.Client.Disconnect()
|
|
||||||
close(user.historySyncs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetExampleConfig() string {
|
|
||||||
return ExampleConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetConfigPtr() interface{} {
|
|
||||||
br.Config = &config.Config{
|
|
||||||
BaseConfig: &br.Bridge.Config,
|
|
||||||
}
|
|
||||||
br.Config.BaseConfig.Bridge = &br.Config.Bridge
|
|
||||||
return br.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
br := &WABridge{
|
|
||||||
usersByMXID: make(map[id.UserID]*User),
|
|
||||||
usersByUsername: make(map[string]*User),
|
|
||||||
spaceRooms: make(map[id.RoomID]*User),
|
|
||||||
managementRooms: make(map[id.RoomID]*User),
|
|
||||||
portalsByMXID: make(map[id.RoomID]*Portal),
|
|
||||||
portalsByJID: make(map[database.PortalKey]*Portal),
|
|
||||||
puppets: make(map[types.JID]*Puppet),
|
|
||||||
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
|
|
||||||
}
|
|
||||||
br.Bridge = bridge.Bridge{
|
|
||||||
Name: "mautrix-whatsapp",
|
|
||||||
URL: "https://github.com/mautrix/whatsapp",
|
|
||||||
Description: "A Matrix-WhatsApp puppeting bridge.",
|
|
||||||
Version: "0.10.9",
|
|
||||||
ProtocolName: "WhatsApp",
|
|
||||||
BeeperServiceName: "whatsapp",
|
|
||||||
BeeperNetworkName: "whatsapp",
|
|
||||||
|
|
||||||
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
|
|
||||||
|
|
||||||
ConfigUpgrader: &configupgrade.StructUpgrader{
|
|
||||||
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
|
|
||||||
Blocks: config.SpacedBlocks,
|
|
||||||
Base: ExampleConfig,
|
|
||||||
},
|
|
||||||
|
|
||||||
Child: br,
|
|
||||||
}
|
|
||||||
br.InitVersion(Tag, Commit, BuildTime)
|
|
||||||
br.WAVersion = strings.FieldsFunc(br.Version, func(r rune) bool { return r == '-' || r == '+' })[0]
|
|
||||||
|
|
||||||
br.Main()
|
|
||||||
}
|
|
147
matrix.go
147
matrix.go
|
@ -1,147 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
|
||||||
"maunium.net/go/mautrix/bridge"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/format"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (br *WABridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) {
|
|
||||||
inviter := brInviter.(*User)
|
|
||||||
puppet := brGhost.(*Puppet)
|
|
||||||
key := database.NewPortalKey(puppet.JID, inviter.JID)
|
|
||||||
portal := br.GetPortalByJID(key)
|
|
||||||
log := br.ZLog.With().
|
|
||||||
Str("action", "create private portal").
|
|
||||||
Stringer("target_room_id", roomID).
|
|
||||||
Stringer("inviter_mxid", inviter.MXID).
|
|
||||||
Stringer("invitee_jid", puppet.JID).
|
|
||||||
Logger()
|
|
||||||
ctx := log.WithContext(context.TODO())
|
|
||||||
|
|
||||||
if len(portal.MXID) == 0 {
|
|
||||||
br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := portal.ensureUserInvited(ctx, inviter)
|
|
||||||
if !ok {
|
|
||||||
log.Warn().Msg("Failed to invite user to existing private chat portal. Redirecting portal to new room...")
|
|
||||||
br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
intent := puppet.DefaultIntent()
|
|
||||||
errorMessage := fmt.Sprintf("You already have a private chat portal with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Config.Homeserver.Domain).MatrixToURL())
|
|
||||||
errorContent := format.RenderMarkdown(errorMessage, true, false)
|
|
||||||
_, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent)
|
|
||||||
log.Debug().Msg("Leaving private chat room from invite as we already have chat with the user")
|
|
||||||
_, _ = intent.LeaveRoom(ctx, roomID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) {
|
|
||||||
log := zerolog.Ctx(ctx)
|
|
||||||
// TODO check if room is already encrypted
|
|
||||||
var existingEncryption event.EncryptionEventContent
|
|
||||||
var encryptionEnabled bool
|
|
||||||
err := portal.MainIntent().StateEvent(ctx, roomID, event.StateEncryption, "", &existingEncryption)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to check if encryption is enabled")
|
|
||||||
} else {
|
|
||||||
encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1
|
|
||||||
}
|
|
||||||
portal.MXID = roomID
|
|
||||||
portal.updateLogger()
|
|
||||||
portal.Topic = PrivateChatTopic
|
|
||||||
portal.Name = puppet.Displayname
|
|
||||||
portal.AvatarURL = puppet.AvatarURL
|
|
||||||
portal.Avatar = puppet.Avatar
|
|
||||||
log.Info().Msg("Created private chat portal from invite")
|
|
||||||
intent := puppet.DefaultIntent()
|
|
||||||
|
|
||||||
if br.Config.Bridge.Encryption.Default || encryptionEnabled {
|
|
||||||
_, err = intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: br.Bot.UserID})
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to invite bridge bot to enable e2be")
|
|
||||||
}
|
|
||||||
err = br.Bot.EnsureJoined(ctx, roomID)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to join as bridge bot to enable e2be")
|
|
||||||
}
|
|
||||||
if !encryptionEnabled {
|
|
||||||
_, err = intent.SendStateEvent(ctx, roomID, event.StateEncryption, "", portal.GetEncryptionEventContent())
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to enable e2be")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
br.AS.StateStore.SetMembership(ctx, roomID, inviter.MXID, event.MembershipJoin)
|
|
||||||
br.AS.StateStore.SetMembership(ctx, roomID, puppet.MXID, event.MembershipJoin)
|
|
||||||
br.AS.StateStore.SetMembership(ctx, roomID, br.Bot.UserID, event.MembershipJoin)
|
|
||||||
portal.Encrypted = true
|
|
||||||
}
|
|
||||||
_, _ = portal.MainIntent().SetRoomTopic(ctx, portal.MXID, portal.Topic)
|
|
||||||
if portal.shouldSetDMRoomMetadata() {
|
|
||||||
_, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name)
|
|
||||||
portal.NameSet = err == nil
|
|
||||||
_, err = portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL)
|
|
||||||
portal.AvatarSet = err == nil
|
|
||||||
}
|
|
||||||
err = portal.Update(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save portal to database after creating from invite")
|
|
||||||
}
|
|
||||||
portal.UpdateBridgeInfo(ctx)
|
|
||||||
_, _ = intent.SendNotice(ctx, roomID, "Private chat portal created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) HandlePresence(ctx context.Context, evt *event.Event) {
|
|
||||||
user := br.GetUserByMXIDIfExists(evt.Sender)
|
|
||||||
if user == nil || !user.IsLoggedIn() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
customPuppet := br.GetPuppetByCustomMXID(user.MXID)
|
|
||||||
// TODO move this flag to the user and/or portal data
|
|
||||||
if customPuppet != nil && !customPuppet.EnablePresence {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
presence := types.PresenceAvailable
|
|
||||||
if evt.Content.AsPresence().Presence != event.PresenceOnline {
|
|
||||||
presence = types.PresenceUnavailable
|
|
||||||
user.zlog.Debug().Msg("Marking offline")
|
|
||||||
} else {
|
|
||||||
user.zlog.Debug().Msg("Marking online")
|
|
||||||
}
|
|
||||||
user.lastPresence = presence
|
|
||||||
if user.Client.Store.PushName != "" {
|
|
||||||
err := user.Client.SendPresence(presence)
|
|
||||||
if err != nil {
|
|
||||||
user.zlog.Err(err).Msg("Failed to set presence")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,322 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
|
||||||
"maunium.net/go/mautrix/bridge/status"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errUserNotConnected = errors.New("you are not connected to WhatsApp")
|
|
||||||
errDifferentUser = errors.New("user is not the recipient of this private chat portal")
|
|
||||||
errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot")
|
|
||||||
errRelaybotNotLoggedIn = errors.New("neither user nor relay bot of chat are logged in")
|
|
||||||
errMNoticeDisabled = errors.New("bridging m.notice messages is disabled")
|
|
||||||
errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
|
|
||||||
errInvalidGeoURI = errors.New("invalid `geo:` URI in message")
|
|
||||||
errUnknownMsgType = errors.New("unknown msgtype")
|
|
||||||
errMediaDownloadFailed = errors.New("failed to download media")
|
|
||||||
errMediaDecryptFailed = errors.New("failed to decrypt media")
|
|
||||||
errMediaConvertFailed = errors.New("failed to convert media")
|
|
||||||
errMediaWhatsAppUploadFailed = errors.New("failed to upload media to WhatsApp")
|
|
||||||
errMediaUnsupportedType = errors.New("unsupported media type")
|
|
||||||
errTargetNotFound = errors.New("target event not found")
|
|
||||||
errReactionDatabaseNotFound = errors.New("reaction database entry not found")
|
|
||||||
errReactionTargetNotFound = errors.New("reaction target message not found")
|
|
||||||
errTargetIsFake = errors.New("target is a fake event")
|
|
||||||
errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else")
|
|
||||||
errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM")
|
|
||||||
errPollMissingQuestion = errors.New("poll message is missing question")
|
|
||||||
errPollDuplicateOption = errors.New("poll options must be unique")
|
|
||||||
|
|
||||||
errGalleryRelay = errors.New("can't send gallery through relay user")
|
|
||||||
errGalleryCaption = errors.New("can't send gallery with caption")
|
|
||||||
|
|
||||||
errEditUnknownTarget = errors.New("unknown edit target message")
|
|
||||||
errEditUnknownTargetType = errors.New("unsupported edited message type")
|
|
||||||
errEditDifferentSender = errors.New("can't edit message sent by another user")
|
|
||||||
errEditTooOld = errors.New("message is too old to be edited")
|
|
||||||
|
|
||||||
errBroadcastReactionNotSupported = errors.New("reacting to status messages is not currently supported")
|
|
||||||
errBroadcastSendDisabled = errors.New("sending status messages is disabled")
|
|
||||||
|
|
||||||
errMessageDisconnected = &whatsmeow.DisconnectedError{Action: "message send"}
|
|
||||||
errMessageRetryDisconnected = &whatsmeow.DisconnectedError{Action: "message send (retry)"}
|
|
||||||
|
|
||||||
errMessageTakingLong = errors.New("bridging the message is taking longer than usual")
|
|
||||||
errTimeoutBeforeHandling = errors.New("message timed out before handling was started")
|
|
||||||
)
|
|
||||||
|
|
||||||
func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, whatsmeow.ErrBroadcastListUnsupported),
|
|
||||||
errors.Is(err, errUnexpectedParsedContentType),
|
|
||||||
errors.Is(err, errUnknownMsgType),
|
|
||||||
errors.Is(err, errInvalidGeoURI),
|
|
||||||
errors.Is(err, whatsmeow.ErrUnknownServer),
|
|
||||||
errors.Is(err, whatsmeow.ErrRecipientADJID),
|
|
||||||
errors.Is(err, errBroadcastReactionNotSupported),
|
|
||||||
errors.Is(err, errBroadcastSendDisabled):
|
|
||||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
|
|
||||||
case errors.Is(err, errMNoticeDisabled):
|
|
||||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, ""
|
|
||||||
case errors.Is(err, errMediaUnsupportedType),
|
|
||||||
errors.Is(err, errPollMissingQuestion),
|
|
||||||
errors.Is(err, errPollDuplicateOption),
|
|
||||||
errors.Is(err, errEditDifferentSender),
|
|
||||||
errors.Is(err, errEditTooOld),
|
|
||||||
errors.Is(err, errEditUnknownTarget),
|
|
||||||
errors.Is(err, errEditUnknownTargetType):
|
|
||||||
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error()
|
|
||||||
case errors.Is(err, errTimeoutBeforeHandling):
|
|
||||||
return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled"
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled"
|
|
||||||
case errors.Is(err, errMessageTakingLong):
|
|
||||||
return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error()
|
|
||||||
case errors.Is(err, errTargetNotFound),
|
|
||||||
errors.Is(err, errTargetIsFake),
|
|
||||||
errors.Is(err, errReactionDatabaseNotFound),
|
|
||||||
errors.Is(err, errReactionTargetNotFound),
|
|
||||||
errors.Is(err, errReactionSentBySomeoneElse),
|
|
||||||
errors.Is(err, errDMSentByOtherUser):
|
|
||||||
return event.MessageStatusGenericError, event.MessageStatusFail, true, false, ""
|
|
||||||
case errors.Is(err, whatsmeow.ErrNotConnected),
|
|
||||||
errors.Is(err, errUserNotConnected):
|
|
||||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, ""
|
|
||||||
case errors.Is(err, errUserNotLoggedIn),
|
|
||||||
errors.Is(err, errDifferentUser),
|
|
||||||
errors.Is(err, errRelaybotNotLoggedIn):
|
|
||||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, ""
|
|
||||||
case errors.Is(err, errMessageDisconnected),
|
|
||||||
errors.Is(err, errMessageRetryDisconnected):
|
|
||||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
|
|
||||||
default:
|
|
||||||
return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sendErrorMessage(ctx context.Context, evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID {
|
|
||||||
if !portal.bridge.Config.Bridge.MessageErrorNotices {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
certainty := "may not have been"
|
|
||||||
if confirmed {
|
|
||||||
certainty = "was not"
|
|
||||||
}
|
|
||||||
var msgType string
|
|
||||||
switch evt.Type {
|
|
||||||
case event.EventMessage:
|
|
||||||
msgType = "message"
|
|
||||||
case event.EventReaction:
|
|
||||||
msgType = "reaction"
|
|
||||||
case event.EventRedaction:
|
|
||||||
msgType = "redaction"
|
|
||||||
case TypeMSC3381PollResponse, TypeMSC3381V2PollResponse:
|
|
||||||
msgType = "poll response"
|
|
||||||
case TypeMSC3381PollStart:
|
|
||||||
msgType = "poll start"
|
|
||||||
default:
|
|
||||||
msgType = "unknown event"
|
|
||||||
}
|
|
||||||
msg := fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, err)
|
|
||||||
if errors.Is(err, errMessageTakingLong) {
|
|
||||||
msg = fmt.Sprintf("\u26a0 Bridging your %s is taking longer than usual", msgType)
|
|
||||||
}
|
|
||||||
content := &event.MessageEventContent{
|
|
||||||
MsgType: event.MsgNotice,
|
|
||||||
Body: msg,
|
|
||||||
}
|
|
||||||
if editID != "" {
|
|
||||||
content.SetEdit(editID)
|
|
||||||
} else {
|
|
||||||
content.SetReply(evt)
|
|
||||||
}
|
|
||||||
resp, err := portal.sendMainIntentMessage(ctx, content)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to send bridging error message")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return resp.EventID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sendStatusEvent(ctx context.Context, evtID, lastRetry id.EventID, err error, deliveredTo *[]id.UserID) {
|
|
||||||
if !portal.bridge.Config.Bridge.MessageStatusEvents {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lastRetry == evtID {
|
|
||||||
lastRetry = ""
|
|
||||||
}
|
|
||||||
intent := portal.bridge.Bot
|
|
||||||
if !portal.Encrypted {
|
|
||||||
// Bridge bot isn't present in unencrypted DMs
|
|
||||||
intent = portal.MainIntent()
|
|
||||||
}
|
|
||||||
content := event.BeeperMessageStatusEventContent{
|
|
||||||
Network: portal.getBridgeInfoStateKey(),
|
|
||||||
RelatesTo: event.RelatesTo{
|
|
||||||
Type: event.RelReference,
|
|
||||||
EventID: evtID,
|
|
||||||
},
|
|
||||||
DeliveredToUsers: deliveredTo,
|
|
||||||
LastRetry: lastRetry,
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
content.Status = event.MessageStatusSuccess
|
|
||||||
} else {
|
|
||||||
content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err)
|
|
||||||
content.Error = err.Error()
|
|
||||||
}
|
|
||||||
_, err = intent.SendMessageEvent(ctx, portal.MXID, event.BeeperMessageStatus, &content)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to send message status event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sendDeliveryReceipt(ctx context.Context, eventID id.EventID) {
|
|
||||||
if portal.bridge.Config.Bridge.DeliveryReceipts {
|
|
||||||
err := portal.bridge.Bot.SendReceipt(ctx, portal.MXID, eventID, event.ReceiptTypeRead, nil)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to mark message as read by bot (Matrix-side delivery receipt)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) {
|
|
||||||
origEvtID := evt.ID
|
|
||||||
if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
|
|
||||||
origEvtID = retryMeta.OriginalEventID
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
level := zerolog.ErrorLevel
|
|
||||||
if part == "Ignoring" {
|
|
||||||
level = zerolog.DebugLevel
|
|
||||||
}
|
|
||||||
zerolog.Ctx(ctx).WithLevel(level).Err(err).Msg(part + " Matrix event")
|
|
||||||
reason, statusCode, isCertain, sendNotice, _ := errorToStatusReason(err)
|
|
||||||
checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode)
|
|
||||||
portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum())
|
|
||||||
if sendNotice {
|
|
||||||
ms.setNoticeID(portal.sendErrorMessage(ctx, evt, err, isCertain, ms.getNoticeID()))
|
|
||||||
}
|
|
||||||
portal.sendStatusEvent(ctx, origEvtID, evt.ID, err, nil)
|
|
||||||
} else {
|
|
||||||
zerolog.Ctx(ctx).Debug().Msg("Successfully handled Matrix event")
|
|
||||||
portal.sendDeliveryReceipt(ctx, evt.ID)
|
|
||||||
portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum())
|
|
||||||
var deliveredTo *[]id.UserID
|
|
||||||
if portal.IsPrivateChat() {
|
|
||||||
deliveredTo = &[]id.UserID{}
|
|
||||||
}
|
|
||||||
portal.sendStatusEvent(ctx, origEvtID, evt.ID, nil, deliveredTo)
|
|
||||||
if prevNotice := ms.popNoticeID(); prevNotice != "" {
|
|
||||||
_, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, prevNotice, mautrix.ReqRedact{
|
|
||||||
Reason: "error resolved",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ms != nil {
|
|
||||||
zerolog.Ctx(ctx).Debug().Object("timings", ms.timings).Msg("Matrix event timings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type messageTimings struct {
|
|
||||||
initReceive time.Duration
|
|
||||||
decrypt time.Duration
|
|
||||||
implicitRR time.Duration
|
|
||||||
portalQueue time.Duration
|
|
||||||
totalReceive time.Duration
|
|
||||||
|
|
||||||
preproc time.Duration
|
|
||||||
convert time.Duration
|
|
||||||
whatsmeow whatsmeow.MessageDebugTimings
|
|
||||||
totalSend time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mt *messageTimings) MarshalZerologObject(e *zerolog.Event) {
|
|
||||||
e.Dur("init_receive", mt.initReceive).
|
|
||||||
Dur("decrypt", mt.decrypt).
|
|
||||||
Dur("implicit_rr", mt.implicitRR).
|
|
||||||
Dur("portal_queue", mt.portalQueue).
|
|
||||||
Dur("total_receive", mt.totalReceive).
|
|
||||||
Dur("preproc", mt.preproc).
|
|
||||||
Dur("convert", mt.convert).
|
|
||||||
Object("whatsmeow", mt.whatsmeow).
|
|
||||||
Dur("total_send", mt.totalSend)
|
|
||||||
}
|
|
||||||
|
|
||||||
type metricSender struct {
|
|
||||||
portal *Portal
|
|
||||||
previousNotice id.EventID
|
|
||||||
lock sync.Mutex
|
|
||||||
completed bool
|
|
||||||
retryNum int
|
|
||||||
timings *messageTimings
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ms *metricSender) getRetryNum() int {
|
|
||||||
if ms != nil {
|
|
||||||
return ms.retryNum
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ms *metricSender) getNoticeID() id.EventID {
|
|
||||||
if ms == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return ms.previousNotice
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ms *metricSender) popNoticeID() id.EventID {
|
|
||||||
if ms == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
evtID := ms.previousNotice
|
|
||||||
ms.previousNotice = ""
|
|
||||||
return evtID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ms *metricSender) setNoticeID(evtID id.EventID) {
|
|
||||||
if ms != nil && ms.previousNotice == "" {
|
|
||||||
ms.previousNotice = evtID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ms *metricSender) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, completed bool) {
|
|
||||||
ms.lock.Lock()
|
|
||||||
defer ms.lock.Unlock()
|
|
||||||
if !completed && ms.completed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ms.portal.sendMessageMetrics(ctx, evt, err, part, ms)
|
|
||||||
ms.retryNum++
|
|
||||||
ms.completed = completed
|
|
||||||
}
|
|
320
metrics.go
320
metrics.go
|
@ -1,320 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2021 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"runtime/debug"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MetricsHandler struct {
|
|
||||||
db *database.Database
|
|
||||||
server *http.Server
|
|
||||||
log zerolog.Logger
|
|
||||||
|
|
||||||
running bool
|
|
||||||
ctx context.Context
|
|
||||||
stopRecorder func()
|
|
||||||
|
|
||||||
matrixEventHandling *prometheus.HistogramVec
|
|
||||||
whatsappMessageAge prometheus.Histogram
|
|
||||||
whatsappMessageHandling *prometheus.HistogramVec
|
|
||||||
countCollection prometheus.Histogram
|
|
||||||
disconnections *prometheus.CounterVec
|
|
||||||
incomingRetryReceipts *prometheus.CounterVec
|
|
||||||
connectionFailures *prometheus.CounterVec
|
|
||||||
puppetCount prometheus.Gauge
|
|
||||||
userCount prometheus.Gauge
|
|
||||||
messageCount prometheus.Gauge
|
|
||||||
portalCount *prometheus.GaugeVec
|
|
||||||
encryptedGroupCount prometheus.Gauge
|
|
||||||
encryptedPrivateCount prometheus.Gauge
|
|
||||||
unencryptedGroupCount prometheus.Gauge
|
|
||||||
unencryptedPrivateCount prometheus.Gauge
|
|
||||||
|
|
||||||
connected prometheus.Gauge
|
|
||||||
connectedState map[string]bool
|
|
||||||
connectedStateLock sync.Mutex
|
|
||||||
loggedIn prometheus.Gauge
|
|
||||||
loggedInState map[string]bool
|
|
||||||
loggedInStateLock sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMetricsHandler(address string, log zerolog.Logger, db *database.Database) *MetricsHandler {
|
|
||||||
portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{
|
|
||||||
Name: "whatsapp_portals_total",
|
|
||||||
Help: "Number of portal rooms on Matrix",
|
|
||||||
}, []string{"type", "encrypted"})
|
|
||||||
return &MetricsHandler{
|
|
||||||
db: db,
|
|
||||||
server: &http.Server{Addr: address, Handler: promhttp.Handler()},
|
|
||||||
log: log,
|
|
||||||
running: false,
|
|
||||||
|
|
||||||
matrixEventHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
|
|
||||||
Name: "matrix_event",
|
|
||||||
Help: "Time spent processing Matrix events",
|
|
||||||
}, []string{"event_type"}),
|
|
||||||
whatsappMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "remote_event_age",
|
|
||||||
Help: "Age of messages received from WhatsApp",
|
|
||||||
Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60},
|
|
||||||
}),
|
|
||||||
whatsappMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
|
|
||||||
Name: "remote_event",
|
|
||||||
Help: "Time spent processing WhatsApp messages",
|
|
||||||
}, []string{"message_type"}),
|
|
||||||
countCollection: promauto.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "whatsapp_count_collection",
|
|
||||||
Help: "Time spent collecting the whatsapp_*_total metrics",
|
|
||||||
}),
|
|
||||||
disconnections: promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Name: "whatsapp_disconnections",
|
|
||||||
Help: "Number of times a Matrix user has been disconnected from WhatsApp",
|
|
||||||
}, []string{"user_id"}),
|
|
||||||
connectionFailures: promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Name: "whatsapp_connection_failures",
|
|
||||||
Help: "Number of times a connection has failed to whatsapp",
|
|
||||||
}, []string{"reason"}),
|
|
||||||
incomingRetryReceipts: promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Name: "whatsapp_incoming_retry_receipts",
|
|
||||||
Help: "Number of times a remote WhatsApp user has requested a retry from the bridge. retry_count = 5 is usually the last attempt (and very likely means a failed message)",
|
|
||||||
}, []string{"retry_count", "message_found"}),
|
|
||||||
puppetCount: promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "whatsapp_puppets_total",
|
|
||||||
Help: "Number of WhatsApp users bridged into Matrix",
|
|
||||||
}),
|
|
||||||
userCount: promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "whatsapp_users_total",
|
|
||||||
Help: "Number of Matrix users using the bridge",
|
|
||||||
}),
|
|
||||||
messageCount: promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "whatsapp_messages_total",
|
|
||||||
Help: "Number of messages bridged",
|
|
||||||
}),
|
|
||||||
portalCount: portalCount,
|
|
||||||
encryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "true"}),
|
|
||||||
encryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "true"}),
|
|
||||||
unencryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "false"}),
|
|
||||||
unencryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "false"}),
|
|
||||||
|
|
||||||
loggedIn: promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "bridge_logged_in",
|
|
||||||
Help: "Users logged into the bridge",
|
|
||||||
}),
|
|
||||||
loggedInState: make(map[string]bool),
|
|
||||||
connected: promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "bridge_connected",
|
|
||||||
Help: "Bridge users connected to WhatsApp",
|
|
||||||
}),
|
|
||||||
connectedState: make(map[string]bool),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func noop() {}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() {
|
|
||||||
if !mh.running {
|
|
||||||
return noop
|
|
||||||
}
|
|
||||||
start := time.Now()
|
|
||||||
return func() {
|
|
||||||
duration := time.Now().Sub(start)
|
|
||||||
mh.matrixEventHandling.
|
|
||||||
With(prometheus.Labels{"event_type": eventType.Type}).
|
|
||||||
Observe(duration.Seconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp time.Time, messageType string) func() {
|
|
||||||
if !mh.running {
|
|
||||||
return noop
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
return func() {
|
|
||||||
duration := time.Now().Sub(start)
|
|
||||||
mh.whatsappMessageHandling.
|
|
||||||
With(prometheus.Labels{"message_type": messageType}).
|
|
||||||
Observe(duration.Seconds())
|
|
||||||
mh.whatsappMessageAge.Observe(time.Now().Sub(timestamp).Seconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackDisconnection(userID id.UserID) {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.disconnections.With(prometheus.Labels{"user_id": string(userID)}).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackConnectionFailure(reason string) {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.connectionFailures.With(prometheus.Labels{"reason": reason}).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackRetryReceipt(count int, found bool) {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.incomingRetryReceipts.With(prometheus.Labels{
|
|
||||||
"retry_count": strconv.Itoa(count),
|
|
||||||
"message_found": strconv.FormatBool(found),
|
|
||||||
}).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackLoginState(jid types.JID, loggedIn bool) {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.loggedInStateLock.Lock()
|
|
||||||
defer mh.loggedInStateLock.Unlock()
|
|
||||||
currentVal, ok := mh.loggedInState[jid.User]
|
|
||||||
if !ok || currentVal != loggedIn {
|
|
||||||
mh.loggedInState[jid.User] = loggedIn
|
|
||||||
if loggedIn {
|
|
||||||
mh.loggedIn.Inc()
|
|
||||||
} else {
|
|
||||||
mh.loggedIn.Dec()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) TrackConnectionState(jid types.JID, connected bool) {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.connectedStateLock.Lock()
|
|
||||||
defer mh.connectedStateLock.Unlock()
|
|
||||||
currentVal, ok := mh.connectedState[jid.User]
|
|
||||||
if !ok || currentVal != connected {
|
|
||||||
mh.connectedState[jid.User] = connected
|
|
||||||
if connected {
|
|
||||||
mh.connected.Inc()
|
|
||||||
} else {
|
|
||||||
mh.connected.Dec()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) updateStats() {
|
|
||||||
start := time.Now()
|
|
||||||
var puppetCount int
|
|
||||||
err := mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM puppet").Scan(&puppetCount)
|
|
||||||
if err != nil {
|
|
||||||
mh.log.Err(err).Msg("Failed to scan number of puppets")
|
|
||||||
} else {
|
|
||||||
mh.puppetCount.Set(float64(puppetCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
var userCount int
|
|
||||||
err = mh.db.QueryRow(mh.ctx, `SELECT COUNT(*) FROM "user"`).Scan(&userCount)
|
|
||||||
if err != nil {
|
|
||||||
mh.log.Err(err).Msg("Failed to scan number of users")
|
|
||||||
} else {
|
|
||||||
mh.userCount.Set(float64(userCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageCount int
|
|
||||||
err = mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM message").Scan(&messageCount)
|
|
||||||
if err != nil {
|
|
||||||
mh.log.Err(err).Msg("Failed to scan number of messages")
|
|
||||||
} else {
|
|
||||||
mh.messageCount.Set(float64(messageCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int
|
|
||||||
err = mh.db.QueryRow(mh.ctx, `
|
|
||||||
SELECT
|
|
||||||
COUNT(CASE WHEN jid LIKE '%@g.us' AND encrypted THEN 1 END) AS encrypted_group_portals,
|
|
||||||
COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND encrypted THEN 1 END) AS encrypted_private_portals,
|
|
||||||
COUNT(CASE WHEN jid LIKE '%@g.us' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals,
|
|
||||||
COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals
|
|
||||||
FROM portal WHERE mxid<>''
|
|
||||||
`).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount)
|
|
||||||
if err != nil {
|
|
||||||
mh.log.Err(err).Msg("Failed to scan number of portals")
|
|
||||||
} else {
|
|
||||||
mh.encryptedGroupCount.Set(float64(encryptedGroupCount))
|
|
||||||
mh.encryptedPrivateCount.Set(float64(encryptedPrivateCount))
|
|
||||||
mh.unencryptedGroupCount.Set(float64(unencryptedGroupCount))
|
|
||||||
mh.unencryptedPrivateCount.Set(float64(encryptedPrivateCount))
|
|
||||||
}
|
|
||||||
mh.countCollection.Observe(time.Now().Sub(start).Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) startUpdatingStats() {
|
|
||||||
defer func() {
|
|
||||||
err := recover()
|
|
||||||
if err != nil {
|
|
||||||
mh.log.WithLevel(zerolog.PanicLevel).
|
|
||||||
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
|
|
||||||
Interface(zerolog.ErrorFieldName, err).
|
|
||||||
Msg("Panic in metric updater")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ticker := time.Tick(10 * time.Second)
|
|
||||||
for {
|
|
||||||
mh.updateStats()
|
|
||||||
select {
|
|
||||||
case <-mh.ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) Start() {
|
|
||||||
mh.running = true
|
|
||||||
mh.ctx, mh.stopRecorder = context.WithCancel(context.Background())
|
|
||||||
go mh.startUpdatingStats()
|
|
||||||
err := mh.server.ListenAndServe()
|
|
||||||
mh.running = false
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
mh.log.Err(err).Msg("Error in metrics listener")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mh *MetricsHandler) Stop() {
|
|
||||||
if !mh.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mh.stopRecorder()
|
|
||||||
err := mh.server.Close()
|
|
||||||
if err != nil {
|
|
||||||
mh.log.Err(err).Msg("Failed to close metrics listener")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -87,10 +87,10 @@ func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistor
|
||||||
Msg("Failed to parse chat JID in history sync")
|
Msg("Failed to parse chat JID in history sync")
|
||||||
continue
|
continue
|
||||||
} else if jid.Server == types.BroadcastServer {
|
} else if jid.Server == types.BroadcastServer {
|
||||||
log.Debug().Str("chat_jid", jid.String()).Msg("Skipping broadcast list in history sync")
|
log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
|
||||||
continue
|
continue
|
||||||
} else if jid.Server == types.HiddenUserServer {
|
} else if jid.Server == types.HiddenUserServer {
|
||||||
log.Debug().Str("chat_jid", jid.String()).Msg("Skipping hidden user JID chat in history sync")
|
log.Debug().Stringer("chat_jid", jid).Msg("Skipping hidden user JID chat in history sync")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalMessageCount += len(conv.GetMessages())
|
totalMessageCount += len(conv.GetMessages())
|
||||||
|
|
808
provisioning.go
808
provisioning.go
|
@ -1,808 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2022 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
_ "net/http/pprof"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/hlog"
|
|
||||||
"go.mau.fi/util/requestlog"
|
|
||||||
"go.mau.fi/whatsmeow"
|
|
||||||
"go.mau.fi/whatsmeow/appstate"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
"maunium.net/go/mautrix/bridge/status"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProvisioningAPI struct {
|
|
||||||
bridge *WABridge
|
|
||||||
log zerolog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Init() {
|
|
||||||
prov.log.Debug().Str("base_path", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API")
|
|
||||||
r := prov.bridge.AS.Router.PathPrefix(prov.bridge.Config.Bridge.Provisioning.Prefix).Subrouter()
|
|
||||||
r.Use(hlog.NewHandler(prov.log))
|
|
||||||
r.Use(requestlog.AccessLogger(true))
|
|
||||||
r.Use(prov.AuthMiddleware)
|
|
||||||
r.HandleFunc("/v1/ping", prov.Ping).Methods(http.MethodGet)
|
|
||||||
r.HandleFunc("/v1/login", prov.Login).Methods(http.MethodGet)
|
|
||||||
r.HandleFunc("/v1/logout", prov.Logout).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/delete_session", prov.DeleteSession).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/disconnect", prov.Disconnect).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/reconnect", prov.Reconnect).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/debug/appstate/{name}", prov.SyncAppState).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/contacts", prov.ListContacts).Methods(http.MethodGet)
|
|
||||||
r.HandleFunc("/v1/groups", prov.ListGroups).Methods(http.MethodGet, http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/resolve_identifier/{number}", prov.ResolveIdentifier).Methods(http.MethodGet)
|
|
||||||
r.HandleFunc("/v1/bulk_resolve_identifier", prov.BulkResolveIdentifier).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/group/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/group/resolve/{inviteCode}", prov.ResolveGroupInvite).Methods(http.MethodPost)
|
|
||||||
r.HandleFunc("/v1/group/join/{inviteCode}", prov.JoinGroup).Methods(http.MethodPost)
|
|
||||||
prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost)
|
|
||||||
prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost)
|
|
||||||
|
|
||||||
if prov.bridge.Config.Bridge.Provisioning.DebugEndpoints {
|
|
||||||
prov.log.Debug().Msg("Enabling debug API at /debug")
|
|
||||||
r := prov.bridge.AS.Router.PathPrefix("/debug").Subrouter()
|
|
||||||
r.Use(prov.AuthMiddleware)
|
|
||||||
r.PathPrefix("/pprof").Handler(http.DefaultServeMux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated, just use /disconnect
|
|
||||||
r.HandleFunc("/v1/delete_connection", prov.Disconnect).Methods(http.MethodPost)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
if len(auth) == 0 && strings.HasSuffix(r.URL.Path, "/login") {
|
|
||||||
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
|
|
||||||
for _, part := range authParts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if strings.HasPrefix(part, "net.maunium.whatsapp.auth-") {
|
|
||||||
auth = part[len("net.maunium.whatsapp.auth-"):]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(auth, "Bearer ") {
|
|
||||||
auth = auth[len("Bearer "):]
|
|
||||||
}
|
|
||||||
if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret {
|
|
||||||
hlog.FromRequest(r).Debug().Msg("Authentication token does not match shared secret")
|
|
||||||
jsonResponse(w, http.StatusForbidden, map[string]interface{}{
|
|
||||||
"error": "Authentication token does not match shared secret",
|
|
||||||
"errcode": "M_FORBIDDEN",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
|
||||||
user := prov.bridge.GetUserByMXID(id.UserID(userID))
|
|
||||||
if user != nil {
|
|
||||||
hlog.FromRequest(r).UpdateContext(func(c zerolog.Context) zerolog.Context {
|
|
||||||
return c.Stringer("user_id", user.MXID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", user)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
ErrCode string `json:"errcode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Response struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user.Session == nil && user.Client == nil {
|
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
|
||||||
Error: "Nothing to purge: no session information stored and no active connection.",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.DeleteConnection()
|
|
||||||
user.DeleteSession(r.Context())
|
|
||||||
jsonResponse(w, http.StatusOK, Response{true, "Session information purged"})
|
|
||||||
user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user.Client == nil {
|
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
|
||||||
Error: "You don't have a WhatsApp connection.",
|
|
||||||
ErrCode: "no connection",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.DeleteConnection()
|
|
||||||
jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"})
|
|
||||||
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user.Client == nil {
|
|
||||||
if user.Session == nil {
|
|
||||||
jsonResponse(w, http.StatusForbidden, Error{
|
|
||||||
Error: "No existing connection and no session. Please log in first.",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
user.Connect()
|
|
||||||
jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.DeleteConnection()
|
|
||||||
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected})
|
|
||||||
user.Connect()
|
|
||||||
jsonResponse(w, http.StatusAccepted, Response{true, "Restarted connection to WhatsApp"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) SyncAppState(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user == nil || user.Client == nil {
|
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
|
||||||
Error: "User is not connected to WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
nameStr := vars["name"]
|
|
||||||
if len(nameStr) == 0 {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "The `name` parameter is required",
|
|
||||||
ErrCode: "missing-name-param",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var name appstate.WAPatchName
|
|
||||||
for _, existingName := range appstate.AllPatchNames {
|
|
||||||
if nameStr == string(existingName) {
|
|
||||||
name = existingName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(name) == 0 {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: fmt.Sprintf("'%s' is not a valid app state patch name", nameStr),
|
|
||||||
ErrCode: "invalid-name-param",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fullStr := r.URL.Query().Get("full")
|
|
||||||
fullSync := len(fullStr) > 0 && (fullStr == "1" || strings.ToLower(fullStr)[0] == 't')
|
|
||||||
err := user.Client.FetchAppState(name, fullSync, false)
|
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{false, err.Error(), "sync-fail"})
|
|
||||||
} else {
|
|
||||||
jsonResponse(w, http.StatusOK, Response{true, fmt.Sprintf("Synced app state %s", name)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) ListContacts(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if user := r.Context().Value("user").(*User); user.Session == nil {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else if contacts, err := user.Session.Contacts.GetAllContacts(); err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: "Internal server error while fetching contact list",
|
|
||||||
ErrCode: "failed to get contacts",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
augmentedContacts := map[types.JID]interface{}{}
|
|
||||||
for jid, contact := range contacts {
|
|
||||||
var avatarUrl id.ContentURI
|
|
||||||
if puppet := prov.bridge.GetPuppetByJID(jid); puppet != nil {
|
|
||||||
avatarUrl = puppet.AvatarURL
|
|
||||||
}
|
|
||||||
augmentedContacts[jid] = map[string]interface{}{
|
|
||||||
"Found": contact.Found,
|
|
||||||
"FirstName": contact.FirstName,
|
|
||||||
"FullName": contact.FullName,
|
|
||||||
"PushName": contact.PushName,
|
|
||||||
"BusinessName": contact.BusinessName,
|
|
||||||
"AvatarURL": avatarUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jsonResponse(w, http.StatusOK, augmentedContacts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) ListGroups(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user.Session == nil {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
err := user.ResyncGroups(r.URL.Query().Get("create_portals") == "true")
|
|
||||||
if err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to resync groups")
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: "Internal server error while resyncing groups",
|
|
||||||
ErrCode: "failed to sync groups",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groups, err := user.getCachedGroupList(); err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to fetch group list")
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: "Internal server error while fetching group list",
|
|
||||||
ErrCode: "failed to get groups",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
jsonResponse(w, http.StatusOK, groups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OtherUserInfo struct {
|
|
||||||
MXID id.UserID `json:"mxid"`
|
|
||||||
JID types.JID `json:"jid"`
|
|
||||||
Name string `json:"displayname"`
|
|
||||||
Avatar id.ContentURI `json:"avatar_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PortalInfo struct {
|
|
||||||
RoomID id.RoomID `json:"room_id"`
|
|
||||||
OtherUser *OtherUserInfo `json:"other_user,omitempty"`
|
|
||||||
GroupInfo *types.GroupInfo `json:"group_info,omitempty"`
|
|
||||||
JustCreated bool `json:"just_created"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func looksEmaily(str string) bool {
|
|
||||||
for _, char := range str {
|
|
||||||
// Characters that are usually in emails, but shouldn't be in phone numbers
|
|
||||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '@' {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) resolveIdentifier(w http.ResponseWriter, r *http.Request) (types.JID, *User) {
|
|
||||||
number, _ := mux.Vars(r)["number"]
|
|
||||||
if strings.HasSuffix(number, "@"+types.DefaultUserServer) {
|
|
||||||
jid, _ := types.ParseJID(number)
|
|
||||||
number = "+" + jid.User
|
|
||||||
}
|
|
||||||
if looksEmaily(number) {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "WhatsApp only supports phone numbers as user identifiers",
|
|
||||||
ErrCode: "number looks like email",
|
|
||||||
})
|
|
||||||
} else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else if resp, err := user.Client.IsOnWhatsApp([]string{number}); err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
|
|
||||||
ErrCode: "error checking number",
|
|
||||||
})
|
|
||||||
} else if len(resp) == 0 {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: "Didn't get a response to checking if the number is on WhatsApp",
|
|
||||||
ErrCode: "error checking number",
|
|
||||||
})
|
|
||||||
} else if !resp[0].IsIn {
|
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
|
||||||
Error: fmt.Sprintf("The server said +%s is not on WhatsApp", resp[0].JID.User),
|
|
||||||
ErrCode: "not on whatsapp",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return resp[0].JID, user
|
|
||||||
}
|
|
||||||
return types.EmptyJID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) StartPM(w http.ResponseWriter, r *http.Request) {
|
|
||||||
jid, user := prov.resolveIdentifier(w, r)
|
|
||||||
if jid.IsEmpty() || user == nil {
|
|
||||||
// resolveIdentifier already responded with an error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
portal, puppet, justCreated, err := user.StartPM(r.Context(), jid, "provisioning API PM")
|
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to create portal: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
statusCode := http.StatusOK
|
|
||||||
if justCreated {
|
|
||||||
statusCode = http.StatusCreated
|
|
||||||
}
|
|
||||||
jsonResponse(w, statusCode, PortalInfo{
|
|
||||||
RoomID: portal.MXID,
|
|
||||||
OtherUser: &OtherUserInfo{
|
|
||||||
JID: puppet.JID,
|
|
||||||
MXID: puppet.MXID,
|
|
||||||
Name: puppet.Displayname,
|
|
||||||
Avatar: puppet.AvatarURL,
|
|
||||||
},
|
|
||||||
JustCreated: justCreated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) ResolveIdentifier(w http.ResponseWriter, r *http.Request) {
|
|
||||||
jid, user := prov.resolveIdentifier(w, r)
|
|
||||||
if jid.IsEmpty() || user == nil {
|
|
||||||
// resolveIdentifier already responded with an error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
portal := user.GetPortalByJID(jid)
|
|
||||||
puppet := user.bridge.GetPuppetByJID(jid)
|
|
||||||
jsonResponse(w, http.StatusOK, PortalInfo{
|
|
||||||
RoomID: portal.MXID,
|
|
||||||
OtherUser: &OtherUserInfo{
|
|
||||||
JID: puppet.JID,
|
|
||||||
MXID: puppet.MXID,
|
|
||||||
Name: puppet.Displayname,
|
|
||||||
Avatar: puppet.AvatarURL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReqBulkResolveIdentifier struct {
|
|
||||||
Numbers []string `json:"numbers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) BulkResolveIdentifier(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req ReqBulkResolveIdentifier
|
|
||||||
var resp []types.IsOnWhatsAppResponse
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "Failed to parse request JSON",
|
|
||||||
ErrCode: "bad json",
|
|
||||||
})
|
|
||||||
} else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else if resp, err = user.Client.IsOnWhatsApp(req.Numbers); err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
|
|
||||||
ErrCode: "error checking number",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
jsonResponse(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
groupID, _ := mux.Vars(r)["groupID"]
|
|
||||||
if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "Invalid group ID",
|
|
||||||
ErrCode: "invalid group id",
|
|
||||||
})
|
|
||||||
} else if info, err := user.Client.GetGroupInfo(jid); err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to get group info by JID")
|
|
||||||
// TODO return better responses for different errors (like ErrGroupNotFound and ErrNotInGroup)
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to get group info: %v", err),
|
|
||||||
ErrCode: "error getting group info",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Importing group chat for user")
|
|
||||||
portal := user.GetPortalByJID(info.JID)
|
|
||||||
statusCode := http.StatusOK
|
|
||||||
if len(portal.MXID) == 0 {
|
|
||||||
err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
|
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to create portal: %v", err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusCreated
|
|
||||||
}
|
|
||||||
jsonResponse(w, statusCode, PortalInfo{
|
|
||||||
RoomID: portal.MXID,
|
|
||||||
GroupInfo: info,
|
|
||||||
JustCreated: statusCode == http.StatusCreated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) resolveGroupInvite(w http.ResponseWriter, r *http.Request) (*types.GroupInfo, *User) {
|
|
||||||
inviteCode, _ := mux.Vars(r)["inviteCode"]
|
|
||||||
if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: "User is not logged into WhatsApp",
|
|
||||||
ErrCode: "no session",
|
|
||||||
})
|
|
||||||
} else if info, err := user.Client.GetGroupInfoFromLink(inviteCode); err != nil {
|
|
||||||
if errors.Is(err, whatsmeow.ErrInviteLinkRevoked) {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: whatsmeow.ErrInviteLinkRevoked.Error(),
|
|
||||||
ErrCode: "invite link revoked",
|
|
||||||
})
|
|
||||||
} else if errors.Is(err, whatsmeow.ErrInviteLinkInvalid) {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, Error{
|
|
||||||
Error: whatsmeow.ErrInviteLinkInvalid.Error(),
|
|
||||||
ErrCode: "invalid invite link",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to get group info from link")
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to fetch group info with link: %v", err),
|
|
||||||
ErrCode: "error getting group info",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return info, user
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) ResolveGroupInvite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
info, user := prov.resolveGroupInvite(w, r)
|
|
||||||
if info == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonResponse(w, http.StatusOK, PortalInfo{
|
|
||||||
RoomID: user.GetPortalByJID(info.JID).MXID,
|
|
||||||
GroupInfo: info,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
info, user := prov.resolveGroupInvite(w, r)
|
|
||||||
if info == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.groupJoinLock.Lock()
|
|
||||||
user.skipGroupCreateDelay = info.JID
|
|
||||||
defer func() {
|
|
||||||
user.skipGroupCreateDelay = types.EmptyJID
|
|
||||||
user.groupJoinLock.Unlock()
|
|
||||||
}()
|
|
||||||
inviteCode, _ := mux.Vars(r)["inviteCode"]
|
|
||||||
if jid, err := user.Client.JoinGroupWithLink(inviteCode); err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Failed to join group")
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to join group: %v", err),
|
|
||||||
ErrCode: "error joining group",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Successfully joined group")
|
|
||||||
portal := user.GetPortalByJID(jid)
|
|
||||||
statusCode := http.StatusOK
|
|
||||||
if len(portal.MXID) == 0 {
|
|
||||||
time.Sleep(500 * time.Millisecond) // Wait for incoming group info to create the portal automatically
|
|
||||||
err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
|
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Failed to create portal: %v", err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusCode = http.StatusCreated
|
|
||||||
}
|
|
||||||
jsonResponse(w, statusCode, PortalInfo{
|
|
||||||
RoomID: portal.MXID,
|
|
||||||
GroupInfo: info,
|
|
||||||
JustCreated: statusCode == http.StatusCreated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
wa := map[string]interface{}{
|
|
||||||
"has_session": user.Session != nil,
|
|
||||||
"management_room": user.ManagementRoom,
|
|
||||||
"conn": nil,
|
|
||||||
}
|
|
||||||
if !user.JID.IsEmpty() {
|
|
||||||
wa["jid"] = user.JID.String()
|
|
||||||
wa["phone"] = "+" + user.JID.User
|
|
||||||
wa["device"] = user.JID.Device
|
|
||||||
if user.Session != nil {
|
|
||||||
wa["platform"] = user.Session.Platform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if user.Client != nil {
|
|
||||||
wa["conn"] = map[string]interface{}{
|
|
||||||
"is_connected": user.Client.IsConnected(),
|
|
||||||
"is_logged_in": user.Client.IsLoggedIn(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"mxid": user.MXID,
|
|
||||||
"admin": user.Admin,
|
|
||||||
"whitelisted": user.Whitelisted,
|
|
||||||
"relay_whitelisted": user.RelayWhitelisted,
|
|
||||||
"whatsapp": wa,
|
|
||||||
}
|
|
||||||
jsonResponse(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user := r.Context().Value("user").(*User)
|
|
||||||
if user.Session == nil {
|
|
||||||
jsonResponse(w, http.StatusOK, Error{
|
|
||||||
Error: "You're not logged in",
|
|
||||||
ErrCode: "not logged in",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
force := strings.ToLower(r.URL.Query().Get("force")) != "false"
|
|
||||||
|
|
||||||
if user.Client == nil {
|
|
||||||
if !force {
|
|
||||||
jsonResponse(w, http.StatusNotFound, Error{
|
|
||||||
Error: "You're not connected",
|
|
||||||
ErrCode: "not connected",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err := user.Client.Logout()
|
|
||||||
if err != nil {
|
|
||||||
hlog.FromRequest(r).Err(err).Msg("Unknown error while logging out")
|
|
||||||
if !force {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, Error{
|
|
||||||
Error: fmt.Sprintf("Unknown error while logging out: %v", err),
|
|
||||||
ErrCode: err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.Session = nil
|
|
||||||
}
|
|
||||||
user.DeleteConnection()
|
|
||||||
}
|
|
||||||
|
|
||||||
user.bridge.Metrics.TrackConnectionState(user.JID, false)
|
|
||||||
user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
|
|
||||||
user.DeleteSession(r.Context())
|
|
||||||
jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."})
|
|
||||||
}
|
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
Subprotocols: []string{"net.maunium.whatsapp.login"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var notNumbers = regexp.MustCompile("[^0-9]")
|
|
||||||
|
|
||||||
func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
|
||||||
user := prov.bridge.GetUserByMXID(id.UserID(userID))
|
|
||||||
log := hlog.FromRequest(r)
|
|
||||||
|
|
||||||
c, err := upgrader.Upgrade(w, r, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to upgrade connection to websocket")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := c.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Debug().Err(err).Msg("Error closing websocket")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
// Read everything so SetCloseHandler() works
|
|
||||||
for {
|
|
||||||
_, _, err = c.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
c.SetCloseHandler(func(code int, text string) error {
|
|
||||||
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
|
|
||||||
cancel()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if userTimezone := r.URL.Query().Get("tz"); userTimezone != "" {
|
|
||||||
log.Debug().Str("timezone", userTimezone).Msg("Updating user timezone")
|
|
||||||
user.Timezone = userTimezone
|
|
||||||
err = user.Update(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save user after updating timezone")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debug().Msg("No timezone provided in request")
|
|
||||||
}
|
|
||||||
|
|
||||||
qrChan, qrReceivedChan, err := user.Login(ctx)
|
|
||||||
expiryTime := time.Now().Add(160 * time.Second)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to log in via provisioning API")
|
|
||||||
if errors.Is(err, ErrAlreadyLoggedIn) {
|
|
||||||
go user.Connect()
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "You're already logged into WhatsApp",
|
|
||||||
ErrCode: "already logged in",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Failed to connect to WhatsApp",
|
|
||||||
ErrCode: "connection error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
phoneNum := r.URL.Query().Get("phone_number")
|
|
||||||
if phoneNum != "" {
|
|
||||||
rawPhone := phoneNum
|
|
||||||
phoneNum = notNumbers.ReplaceAllString(phoneNum, "")
|
|
||||||
if len(phoneNum) < 7 || strings.HasPrefix(phoneNum, "0") {
|
|
||||||
log.Warn().Str("phone", rawPhone).Msg("Invalid phone number in login request")
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]any{
|
|
||||||
"error": "invalid phone number",
|
|
||||||
"phone": rawPhone,
|
|
||||||
})
|
|
||||||
errorMsg := "Invalid phone number"
|
|
||||||
if len(phoneNum) > 6 {
|
|
||||||
errorMsg = "Please enter the phone number in international format"
|
|
||||||
}
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: errorMsg,
|
|
||||||
ErrCode: "invalid phone number",
|
|
||||||
})
|
|
||||||
go user.DeleteConnection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-qrReceivedChan:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
log.Warn().Msg("Didn't receive QR event within 5 seconds of starting login")
|
|
||||||
}
|
|
||||||
pairingCode, err := user.Client.PairPhone(phoneNum, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to start phone code login")
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]any{
|
|
||||||
"error": "phone code start fail",
|
|
||||||
"go_error": err.Error(),
|
|
||||||
})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Failed to request pairing code",
|
|
||||||
ErrCode: "code error",
|
|
||||||
})
|
|
||||||
go user.DeleteConnection()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
log.Debug().Msg("Started phone number login")
|
|
||||||
_ = c.WriteJSON(map[string]any{
|
|
||||||
"pairing_code": pairingCode,
|
|
||||||
"timeout": int(time.Until(expiryTime).Seconds()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msg("Started login via provisioning API")
|
|
||||||
Analytics.Track(user.MXID, "$login_start")
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case evt := <-qrChan:
|
|
||||||
switch evt.Event {
|
|
||||||
case whatsmeow.QRChannelSuccess.Event:
|
|
||||||
jid := user.Client.Store.ID
|
|
||||||
log.Debug().Stringer("jid", jid).Msg("Successful login via provisioning API")
|
|
||||||
Analytics.Track(user.MXID, "$login_success")
|
|
||||||
_ = c.WriteJSON(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"jid": jid,
|
|
||||||
"phone": fmt.Sprintf("+%s", jid.User),
|
|
||||||
"platform": user.Client.Store.Platform,
|
|
||||||
})
|
|
||||||
case whatsmeow.QRChannelTimeout.Event:
|
|
||||||
log.Debug().Msg("Login via provisioning API timed out")
|
|
||||||
errCode := "login timed out"
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "QR code scan timed out. Please try again.",
|
|
||||||
ErrCode: errCode,
|
|
||||||
})
|
|
||||||
case whatsmeow.QRChannelErrUnexpectedEvent.Event:
|
|
||||||
log.Debug().Msg("Login via provisioning API failed due to unexpected event")
|
|
||||||
errCode := "unexpected event"
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Got unexpected event while waiting for QRs, perhaps you're already logged in?",
|
|
||||||
ErrCode: errCode,
|
|
||||||
})
|
|
||||||
case whatsmeow.QRChannelClientOutdated.Event:
|
|
||||||
log.Debug().Msg("Login via provisioning API failed due to outdated client")
|
|
||||||
errCode := "bridge outdated"
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Got client outdated error while waiting for QRs. The bridge must be updated to continue.",
|
|
||||||
ErrCode: errCode,
|
|
||||||
})
|
|
||||||
case whatsmeow.QRChannelScannedWithoutMultidevice.Event:
|
|
||||||
errCode := "multidevice not enabled"
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Please enable the WhatsApp multidevice beta and scan the QR code again.",
|
|
||||||
ErrCode: errCode,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
case "error":
|
|
||||||
errCode := "fatal error"
|
|
||||||
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
|
|
||||||
_ = c.WriteJSON(Error{
|
|
||||||
Error: "Fatal error while logging in",
|
|
||||||
ErrCode: errCode,
|
|
||||||
})
|
|
||||||
case "code":
|
|
||||||
Analytics.Track(user.MXID, "$qrcode_retrieved")
|
|
||||||
_ = c.WriteJSON(map[string]interface{}{
|
|
||||||
"code": evt.Code,
|
|
||||||
"timeout": int(evt.Timeout.Seconds()),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
422
puppet.go
422
puppet.go
|
@ -1,422 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2021 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
|
||||||
"maunium.net/go/mautrix/appservice"
|
|
||||||
"maunium.net/go/mautrix/bridge"
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix-whatsapp/config"
|
|
||||||
"maunium.net/go/mautrix-whatsapp/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
var userIDRegex *regexp.Regexp
|
|
||||||
|
|
||||||
func (br *WABridge) ParsePuppetMXID(mxid id.UserID) (jid types.JID, ok bool) {
|
|
||||||
if userIDRegex == nil {
|
|
||||||
userIDRegex = br.Config.MakeUserIDRegex("([0-9]+)")
|
|
||||||
}
|
|
||||||
match := userIDRegex.FindStringSubmatch(string(mxid))
|
|
||||||
if len(match) == 2 {
|
|
||||||
jid = types.NewJID(match[1], types.DefaultUserServer)
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
|
|
||||||
jid, ok := br.ParsePuppetMXID(mxid)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return br.GetPuppetByJID(jid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetPuppetByJID(jid types.JID) *Puppet {
|
|
||||||
ctx := context.TODO()
|
|
||||||
jid = jid.ToNonAD()
|
|
||||||
if jid.Server == types.LegacyUserServer {
|
|
||||||
jid.Server = types.DefaultUserServer
|
|
||||||
} else if jid.Server != types.DefaultUserServer {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
br.puppetsLock.Lock()
|
|
||||||
defer br.puppetsLock.Unlock()
|
|
||||||
puppet, ok := br.puppets[jid]
|
|
||||||
if !ok {
|
|
||||||
dbPuppet, err := br.DB.Puppet.Get(ctx, jid)
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to get puppet from database")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if dbPuppet == nil {
|
|
||||||
dbPuppet = br.DB.Puppet.New()
|
|
||||||
dbPuppet.JID = jid
|
|
||||||
err = dbPuppet.Insert(ctx)
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to insert new puppet to database")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
puppet = br.NewPuppet(dbPuppet)
|
|
||||||
br.puppets[puppet.JID] = puppet
|
|
||||||
if len(puppet.CustomMXID) > 0 {
|
|
||||||
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return puppet
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
|
|
||||||
br.puppetsLock.Lock()
|
|
||||||
defer br.puppetsLock.Unlock()
|
|
||||||
puppet, ok := br.puppetsByCustomMXID[mxid]
|
|
||||||
if !ok {
|
|
||||||
dbPuppet, err := br.DB.Puppet.GetByCustomMXID(context.TODO(), mxid)
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.Err(err).Stringer("mxid", mxid).Msg("Failed to get puppet by custom mxid from database")
|
|
||||||
}
|
|
||||||
if dbPuppet == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
puppet = br.NewPuppet(dbPuppet)
|
|
||||||
br.puppets[puppet.JID] = puppet
|
|
||||||
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
|
|
||||||
}
|
|
||||||
return puppet
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
|
|
||||||
p := user.bridge.GetPuppetByCustomMXID(user.MXID)
|
|
||||||
if p == nil || p.CustomIntent() == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (user *User) GetIGhost() bridge.Ghost {
|
|
||||||
if user.JID.IsEmpty() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p := user.bridge.GetPuppetByJID(user.JID)
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) IsGhost(id id.UserID) bool {
|
|
||||||
_, ok := br.ParsePuppetMXID(id)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetIGhost(id id.UserID) bridge.Ghost {
|
|
||||||
p := br.GetPuppetByMXID(id)
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) GetMXID() id.UserID {
|
|
||||||
return puppet.MXID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetAllPuppetsWithCustomMXID() []*Puppet {
|
|
||||||
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID(context.TODO()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) GetAllPuppets() []*Puppet {
|
|
||||||
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll(context.TODO()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet, err error) []*Puppet {
|
|
||||||
if err != nil {
|
|
||||||
br.ZLog.Err(err).Msg("Error getting puppets from database")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
br.puppetsLock.Lock()
|
|
||||||
defer br.puppetsLock.Unlock()
|
|
||||||
output := make([]*Puppet, len(dbPuppets))
|
|
||||||
for index, dbPuppet := range dbPuppets {
|
|
||||||
if dbPuppet == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
puppet, ok := br.puppets[dbPuppet.JID]
|
|
||||||
if !ok {
|
|
||||||
puppet = br.NewPuppet(dbPuppet)
|
|
||||||
br.puppets[dbPuppet.JID] = puppet
|
|
||||||
if len(dbPuppet.CustomMXID) > 0 {
|
|
||||||
br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output[index] = puppet
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) FormatPuppetMXID(jid types.JID) id.UserID {
|
|
||||||
return id.NewUserID(
|
|
||||||
br.Config.Bridge.FormatUsername(jid.User),
|
|
||||||
br.Config.Homeserver.Domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (br *WABridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
|
|
||||||
return &Puppet{
|
|
||||||
Puppet: dbPuppet,
|
|
||||||
bridge: br,
|
|
||||||
zlog: br.ZLog.With().Stringer("puppet_jid", dbPuppet.JID).Logger(),
|
|
||||||
|
|
||||||
MXID: br.FormatPuppetMXID(dbPuppet.JID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Puppet struct {
|
|
||||||
*database.Puppet
|
|
||||||
|
|
||||||
bridge *WABridge
|
|
||||||
zlog zerolog.Logger
|
|
||||||
|
|
||||||
typingIn id.RoomID
|
|
||||||
typingAt time.Time
|
|
||||||
|
|
||||||
MXID id.UserID
|
|
||||||
|
|
||||||
customIntent *appservice.IntentAPI
|
|
||||||
customUser *User
|
|
||||||
|
|
||||||
syncLock sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ bridge.GhostWithProfile = (*Puppet)(nil)
|
|
||||||
|
|
||||||
func (puppet *Puppet) GetDisplayname() string {
|
|
||||||
return puppet.Displayname
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) GetAvatarURL() id.ContentURI {
|
|
||||||
return puppet.AvatarURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
|
|
||||||
if puppet.customIntent == nil || portal.Key.JID == puppet.JID || (portal.Key.JID.Server == types.BroadcastServer && portal.Key.Receiver != puppet.JID) {
|
|
||||||
return puppet.DefaultIntent()
|
|
||||||
}
|
|
||||||
return puppet.customIntent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
|
|
||||||
return puppet.customIntent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
|
|
||||||
return puppet.bridge.AS.Intent(puppet.MXID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateAvatar(ctx context.Context, source *User, forcePortalSync bool) bool {
|
|
||||||
changed := source.updateAvatar(ctx, puppet.JID, false, &puppet.Avatar, &puppet.AvatarURL, &puppet.AvatarSet, puppet.DefaultIntent())
|
|
||||||
if !changed || puppet.Avatar == "unauthorized" {
|
|
||||||
if forcePortalSync {
|
|
||||||
go puppet.updatePortalAvatar(ctx)
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar from puppet")
|
|
||||||
} else {
|
|
||||||
puppet.AvatarSet = true
|
|
||||||
}
|
|
||||||
go puppet.updatePortalAvatar(ctx)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateName(ctx context.Context, contact types.ContactInfo, forcePortalSync bool) bool {
|
|
||||||
newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(puppet.JID, contact)
|
|
||||||
if (puppet.Displayname != newName || !puppet.NameSet) && quality >= puppet.NameQuality {
|
|
||||||
oldName := puppet.Displayname
|
|
||||||
puppet.Displayname = newName
|
|
||||||
puppet.NameQuality = quality
|
|
||||||
puppet.NameSet = false
|
|
||||||
err := puppet.DefaultIntent().SetDisplayName(ctx, newName)
|
|
||||||
if err == nil {
|
|
||||||
puppet.zlog.Debug().Str("old_name", oldName).Str("new_name", newName).Msg("Updated name")
|
|
||||||
puppet.NameSet = true
|
|
||||||
go puppet.updatePortalName(ctx)
|
|
||||||
} else {
|
|
||||||
puppet.zlog.Err(err).Msg("Failed to set displayname")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} else if forcePortalSync {
|
|
||||||
go puppet.updatePortalName(ctx)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) UpdateContactInfo(ctx context.Context) bool {
|
|
||||||
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if puppet.ContactInfoSet {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
contactInfo := map[string]any{
|
|
||||||
"com.beeper.bridge.identifiers": []string{
|
|
||||||
fmt.Sprintf("tel:+%s", puppet.JID.User),
|
|
||||||
fmt.Sprintf("whatsapp:%s", puppet.JID.String()),
|
|
||||||
},
|
|
||||||
"com.beeper.bridge.remote_id": puppet.JID.String(),
|
|
||||||
"com.beeper.bridge.service": "whatsapp",
|
|
||||||
"com.beeper.bridge.network": "whatsapp",
|
|
||||||
}
|
|
||||||
err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo)
|
|
||||||
if err != nil {
|
|
||||||
puppet.zlog.Err(err).Msg("Failed to store custom contact info in profile")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
puppet.ContactInfoSet = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
|
|
||||||
for _, portal := range puppet.bridge.GetAllPortalsByJID(puppet.JID) {
|
|
||||||
// Get room create lock to prevent races between receiving contact info and room creation.
|
|
||||||
portal.roomCreateLock.Lock()
|
|
||||||
meta(portal)
|
|
||||||
portal.roomCreateLock.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) updatePortalAvatar(ctx context.Context) {
|
|
||||||
puppet.updatePortalMeta(func(portal *Portal) {
|
|
||||||
if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (portal.AvatarSet || !portal.shouldSetDMRoomMetadata()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
portal.AvatarURL = puppet.AvatarURL
|
|
||||||
portal.Avatar = puppet.Avatar
|
|
||||||
portal.AvatarSet = false
|
|
||||||
if len(portal.MXID) > 0 && !portal.shouldSetDMRoomMetadata() {
|
|
||||||
portal.UpdateBridgeInfo(ctx)
|
|
||||||
} else if len(portal.MXID) > 0 {
|
|
||||||
_, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, puppet.AvatarURL)
|
|
||||||
if err != nil {
|
|
||||||
portal.zlog.Err(err).Msg("Failed to set avatar from puppet")
|
|
||||||
} else {
|
|
||||||
portal.AvatarSet = true
|
|
||||||
portal.UpdateBridgeInfo(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err := portal.Update(ctx)
|
|
||||||
if err != nil {
|
|
||||||
portal.zlog.Err(err).Msg("Failed to save portal after updating avatar from puppet")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) updatePortalName(ctx context.Context) {
|
|
||||||
puppet.updatePortalMeta(func(portal *Portal) {
|
|
||||||
portal.UpdateName(ctx, puppet.Displayname, types.EmptyJID, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) SyncContact(ctx context.Context, source *User, onlyIfNoName, shouldHavePushName bool, reason string) {
|
|
||||||
if puppet == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if onlyIfNoName && len(puppet.Displayname) > 0 && (!shouldHavePushName || puppet.NameQuality > config.NameQualityPhone) {
|
|
||||||
source.EnqueuePuppetResync(puppet)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log := zerolog.Ctx(ctx).With().
|
|
||||||
Str("method", "Puppet.SyncContact").
|
|
||||||
Stringer("puppet_jid", puppet.JID).
|
|
||||||
Stringer("source_user_jid", source.JID).
|
|
||||||
Stringer("source_user_mxid", source.MXID).
|
|
||||||
Logger()
|
|
||||||
ctx = log.WithContext(ctx)
|
|
||||||
|
|
||||||
contact, err := source.Client.Store.Contacts.GetContact(puppet.JID)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).
|
|
||||||
Stringer("source_mxid", source.MXID).
|
|
||||||
Str("sync_reason", reason).
|
|
||||||
Msg("Failed to get contact info through user in SyncContact")
|
|
||||||
} else if !contact.Found {
|
|
||||||
log.Warn().
|
|
||||||
Stringer("source_mxid", source.MXID).
|
|
||||||
Str("sync_reason", reason).
|
|
||||||
Msg("No contact info found through user in SyncContact")
|
|
||||||
}
|
|
||||||
puppet.syncInternal(ctx, source, &contact, false, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) Sync(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
|
|
||||||
log := zerolog.Ctx(ctx).With().
|
|
||||||
Str("method", "Puppet.Sync").
|
|
||||||
Stringer("puppet_jid", puppet.JID).
|
|
||||||
Stringer("source_user_jid", source.JID).
|
|
||||||
Stringer("source_user_mxid", source.MXID).
|
|
||||||
Logger()
|
|
||||||
ctx = log.WithContext(ctx)
|
|
||||||
puppet.syncInternal(ctx, source, contact, forceAvatarSync, forcePortalSync)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (puppet *Puppet) syncInternal(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
|
|
||||||
log := zerolog.Ctx(ctx)
|
|
||||||
puppet.syncLock.Lock()
|
|
||||||
defer puppet.syncLock.Unlock()
|
|
||||||
err := puppet.DefaultIntent().EnsureRegistered(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to ensure registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Stringer("source_jid", source.JID).Msg("Syncing info through user")
|
|
||||||
|
|
||||||
update := false
|
|
||||||
if contact != nil {
|
|
||||||
if puppet.JID.User == source.JID.User {
|
|
||||||
contact.PushName = source.Client.Store.PushName
|
|
||||||
}
|
|
||||||
update = puppet.UpdateName(ctx, *contact, forcePortalSync) || update
|
|
||||||
}
|
|
||||||
if len(puppet.Avatar) == 0 || forceAvatarSync || puppet.bridge.Config.Bridge.UserAvatarSync {
|
|
||||||
update = puppet.UpdateAvatar(ctx, source, forcePortalSync) || update
|
|
||||||
}
|
|
||||||
update = puppet.UpdateContactInfo(ctx) || update
|
|
||||||
if update || puppet.LastSync.Add(24*time.Hour).Before(time.Now()) {
|
|
||||||
puppet.LastSync = time.Now()
|
|
||||||
err = puppet.Update(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to save puppet after sync")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
195
urlpreview.go
195
urlpreview.go
|
@ -1,195 +0,0 @@
|
||||||
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
||||||
// Copyright (C) 2024 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"image"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"golang.org/x/net/idna"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow"
|
|
||||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/appservice"
|
|
||||||
"maunium.net/go/mautrix/crypto/attachment"
|
|
||||||
"maunium.net/go/mautrix/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (portal *Portal) convertURLPreviewToBeeper(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.ExtendedTextMessage) []*event.BeeperLinkPreview {
|
|
||||||
if msg.GetMatchedText() == "" {
|
|
||||||
return []*event.BeeperLinkPreview{}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := &event.BeeperLinkPreview{
|
|
||||||
MatchedURL: msg.GetMatchedText(),
|
|
||||||
LinkPreview: event.LinkPreview{
|
|
||||||
CanonicalURL: msg.GetCanonicalURL(),
|
|
||||||
Title: msg.GetTitle(),
|
|
||||||
Description: msg.GetDescription(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if len(output.CanonicalURL) == 0 {
|
|
||||||
output.CanonicalURL = output.MatchedURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var thumbnailData []byte
|
|
||||||
if msg.ThumbnailDirectPath != nil {
|
|
||||||
var err error
|
|
||||||
thumbnailData, err = source.Client.DownloadThumbnail(msg)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to download thumbnail for link preview")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if thumbnailData == nil && msg.JPEGThumbnail != nil {
|
|
||||||
thumbnailData = msg.JPEGThumbnail
|
|
||||||
}
|
|
||||||
if thumbnailData != nil {
|
|
||||||
output.ImageHeight = int(msg.GetThumbnailHeight())
|
|
||||||
output.ImageWidth = int(msg.GetThumbnailWidth())
|
|
||||||
if output.ImageHeight == 0 || output.ImageWidth == 0 {
|
|
||||||
src, _, err := image.Decode(bytes.NewReader(thumbnailData))
|
|
||||||
if err == nil {
|
|
||||||
imageBounds := src.Bounds()
|
|
||||||
output.ImageWidth, output.ImageHeight = imageBounds.Max.X, imageBounds.Max.Y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.ImageSize = len(thumbnailData)
|
|
||||||
output.ImageType = http.DetectContentType(thumbnailData)
|
|
||||||
uploadData, uploadMime := thumbnailData, output.ImageType
|
|
||||||
if portal.Encrypted {
|
|
||||||
crypto := attachment.NewEncryptedFile()
|
|
||||||
crypto.EncryptInPlace(uploadData)
|
|
||||||
uploadMime = "application/octet-stream"
|
|
||||||
output.ImageEncryption = &event.EncryptedFileInfo{EncryptedFile: *crypto}
|
|
||||||
}
|
|
||||||
resp, err := intent.UploadBytes(ctx, uploadData, uploadMime)
|
|
||||||
if err != nil {
|
|
||||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload thumbnail for link preview")
|
|
||||||
} else {
|
|
||||||
if output.ImageEncryption != nil {
|
|
||||||
output.ImageEncryption.URL = resp.ContentURI.CUString()
|
|
||||||
} else {
|
|
||||||
output.ImageURL = resp.ContentURI.CUString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if msg.GetPreviewType() == waProto.ExtendedTextMessage_VIDEO {
|
|
||||||
output.Type = "video.other"
|
|
||||||
}
|
|
||||||
|
|
||||||
return []*event.BeeperLinkPreview{output}
|
|
||||||
}
|
|
||||||
|
|
||||||
var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`)
|
|
||||||
|
|
||||||
func (portal *Portal) convertURLPreviewToWhatsApp(ctx context.Context, sender *User, content *event.MessageEventContent, dest *waProto.ExtendedTextMessage) bool {
|
|
||||||
log := zerolog.Ctx(ctx)
|
|
||||||
var preview *event.BeeperLinkPreview
|
|
||||||
|
|
||||||
if content.BeeperLinkPreviews != nil {
|
|
||||||
// Note: this check explicitly happens after checking for nil: empty arrays are treated as no previews,
|
|
||||||
// but omitting the field means the bridge may look for URLs in the message text.
|
|
||||||
if len(content.BeeperLinkPreviews) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// WhatsApp only supports a single preview.
|
|
||||||
preview = content.BeeperLinkPreviews[0]
|
|
||||||
} else if portal.bridge.Config.Bridge.URLPreviews {
|
|
||||||
if matchedURL := URLRegex.FindString(content.Body); len(matchedURL) == 0 {
|
|
||||||
return false
|
|
||||||
} else if parsed, err := url.Parse(matchedURL); err != nil {
|
|
||||||
return false
|
|
||||||
} else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil {
|
|
||||||
return false
|
|
||||||
} else if mxPreview, err := portal.MainIntent().GetURLPreview(ctx, parsed.String()); err != nil {
|
|
||||||
log.Err(err).Str("url", matchedURL).Msg("Failed to fetch URL preview")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
preview = &event.BeeperLinkPreview{
|
|
||||||
LinkPreview: *mxPreview,
|
|
||||||
MatchedURL: matchedURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if preview == nil || len(preview.MatchedURL) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
dest.MatchedText = &preview.MatchedURL
|
|
||||||
if len(preview.CanonicalURL) > 0 {
|
|
||||||
dest.CanonicalURL = &preview.CanonicalURL
|
|
||||||
}
|
|
||||||
if len(preview.Description) > 0 {
|
|
||||||
dest.Description = &preview.Description
|
|
||||||
}
|
|
||||||
if len(preview.Title) > 0 {
|
|
||||||
dest.Title = &preview.Title
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(preview.Type, "video.") {
|
|
||||||
dest.PreviewType = waProto.ExtendedTextMessage_VIDEO.Enum()
|
|
||||||
}
|
|
||||||
imageMXC := preview.ImageURL.ParseOrIgnore()
|
|
||||||
if preview.ImageEncryption != nil {
|
|
||||||
imageMXC = preview.ImageEncryption.URL.ParseOrIgnore()
|
|
||||||
}
|
|
||||||
if !imageMXC.IsEmpty() {
|
|
||||||
data, err := portal.MainIntent().DownloadBytes(ctx, imageMXC)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Str("image_url", string(preview.ImageURL)).Msg("Failed to download URL preview image")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if preview.ImageEncryption != nil {
|
|
||||||
err = preview.ImageEncryption.DecryptInPlace(data)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to decrypt URL preview image")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dest.MediaKeyTimestamp = proto.Int64(time.Now().Unix())
|
|
||||||
uploadResp, err := sender.Client.Upload(ctx, data, whatsmeow.MediaLinkThumbnail)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to reupload URL preview thumbnail")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
dest.ThumbnailSHA256 = uploadResp.FileSHA256
|
|
||||||
dest.ThumbnailEncSHA256 = uploadResp.FileEncSHA256
|
|
||||||
dest.ThumbnailDirectPath = &uploadResp.DirectPath
|
|
||||||
dest.MediaKey = uploadResp.MediaKey
|
|
||||||
var width, height int
|
|
||||||
dest.JPEGThumbnail, width, height, err = createThumbnailAndGetSize(data, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to create JPEG thumbnail for URL preview")
|
|
||||||
}
|
|
||||||
if preview.ImageHeight > 0 && preview.ImageWidth > 0 {
|
|
||||||
dest.ThumbnailWidth = proto.Uint32(uint32(preview.ImageWidth))
|
|
||||||
dest.ThumbnailHeight = proto.Uint32(uint32(preview.ImageHeight))
|
|
||||||
} else if width > 0 && height > 0 {
|
|
||||||
dest.ThumbnailWidth = proto.Uint32(uint32(width))
|
|
||||||
dest.ThumbnailHeight = proto.Uint32(uint32(height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue