From ea2d8ba07d054f5e8c1ad4091198144266e7cb2f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 27 Sep 2024 16:19:39 +0300 Subject: [PATCH] all: delete legacy bridge --- .gitlab-ci.yml | 5 +- .pre-commit-config.yaml | 15 +- CHANGELOG.md | 11 + Dockerfile | 9 +- Dockerfile.ci | 7 +- Dockerfile.dev | 13 - Dockerfile.v2.ci | 15 - LICENSE.exceptions | 3 - ROADMAP.md | 26 +- analytics.go | 96 - backfillqueue.go | 136 - bridgestate.go | 101 - build-v2.sh | 4 - build.sh | 4 +- commands.go | 1248 ---- config/bridge.go | 337 - config/config.go | 54 - config/upgrade.go | 200 - custompuppet.go | 99 - database/backfillqueue.go | 253 - database/backfillstate.go | 94 - database/database.go | 94 - database/disappearingmessage.go | 102 - database/historysync.go | 302 - database/mediabackfillrequest.go | 106 - database/message.go | 199 - database/polloption.go | 121 - database/portal.go | 217 - database/puppet.go | 153 - database/reaction.go | 89 - database/upgrades/00-latest-revision.sql | 208 - database/upgrades/36-phone-last-seen-ts.sql | 3 - database/upgrades/37-message-error-string.sql | 11 - database/upgrades/38-phone-ping-ts.sql | 3 - database/upgrades/39-reactions.sql | 21 - database/upgrades/40-prioritized-backfill.sql | 22 - database/upgrades/41-historysync-store.sql | 31 - .../upgrades/42-backfillqueue-type-order.sql | 9 - .../upgrades/43-media-backfill-requests.sql | 14 - database/upgrades/44-user-timezone.sql | 3 - .../45-backfillqueue-dispatch-time.sql | 5 - ...6-history-sync-message-added-timestamp.sql | 3 - database/upgrades/47-room-backfill-state.sql | 13 - .../48-crypto-store-handling-split.sql | 7 - .../49-backfill-state-timestamp-bigint.sql | 13 - .../upgrades/50-puppet-background-sync.sql | 13 - .../upgrades/51-portal-background-sync.sql | 3 - database/upgrades/52-communities.sql | 5 - database/upgrades/53-community-index.sql | 2 - database/upgrades/54-poll-option-id-map.sql | 11 - database/upgrades/55-add-contact-info.sql | 3 - database/upgrades/56-message-sender-mxid.sql | 2 - .../upgrades/57-message-timestamp-index.sql | 2 - database/upgrades/upgrades.go | 37 - database/user.go | 146 - database/userportal.go | 114 - disappear.go | 102 - docker-run.sh | 6 +- example-config.yaml | 481 -- formatting.go | 208 - go.mod | 20 +- go.sum | 23 +- historysync.go | 1024 --- main.go | 279 - matrix.go | 147 - messagetracking.go | 322 - metrics.go | 320 - pkg/connector/backfill.go | 4 +- portal.go | 5512 ----------------- provisioning.go | 808 --- puppet.go | 422 -- urlpreview.go | 195 - user.go | 1639 ----- 73 files changed, 53 insertions(+), 16276 deletions(-) delete mode 100644 Dockerfile.dev delete mode 100644 Dockerfile.v2.ci delete mode 100644 analytics.go delete mode 100644 backfillqueue.go delete mode 100644 bridgestate.go delete mode 100755 build-v2.sh delete mode 100644 commands.go delete mode 100644 config/bridge.go delete mode 100644 config/config.go delete mode 100644 config/upgrade.go delete mode 100644 custompuppet.go delete mode 100644 database/backfillqueue.go delete mode 100644 database/backfillstate.go delete mode 100644 database/database.go delete mode 100644 database/disappearingmessage.go delete mode 100644 database/historysync.go delete mode 100644 database/mediabackfillrequest.go delete mode 100644 database/message.go delete mode 100644 database/polloption.go delete mode 100644 database/portal.go delete mode 100644 database/puppet.go delete mode 100644 database/reaction.go delete mode 100644 database/upgrades/00-latest-revision.sql delete mode 100644 database/upgrades/36-phone-last-seen-ts.sql delete mode 100644 database/upgrades/37-message-error-string.sql delete mode 100644 database/upgrades/38-phone-ping-ts.sql delete mode 100644 database/upgrades/39-reactions.sql delete mode 100644 database/upgrades/40-prioritized-backfill.sql delete mode 100644 database/upgrades/41-historysync-store.sql delete mode 100644 database/upgrades/42-backfillqueue-type-order.sql delete mode 100644 database/upgrades/43-media-backfill-requests.sql delete mode 100644 database/upgrades/44-user-timezone.sql delete mode 100644 database/upgrades/45-backfillqueue-dispatch-time.sql delete mode 100644 database/upgrades/46-history-sync-message-added-timestamp.sql delete mode 100644 database/upgrades/47-room-backfill-state.sql delete mode 100644 database/upgrades/48-crypto-store-handling-split.sql delete mode 100644 database/upgrades/49-backfill-state-timestamp-bigint.sql delete mode 100644 database/upgrades/50-puppet-background-sync.sql delete mode 100644 database/upgrades/51-portal-background-sync.sql delete mode 100644 database/upgrades/52-communities.sql delete mode 100644 database/upgrades/53-community-index.sql delete mode 100644 database/upgrades/54-poll-option-id-map.sql delete mode 100644 database/upgrades/55-add-contact-info.sql delete mode 100644 database/upgrades/56-message-sender-mxid.sql delete mode 100644 database/upgrades/57-message-timestamp-index.sql delete mode 100644 database/upgrades/upgrades.go delete mode 100644 database/user.go delete mode 100644 database/userportal.go delete mode 100644 disappear.go delete mode 100644 example-config.yaml delete mode 100644 formatting.go delete mode 100644 historysync.go delete mode 100644 main.go delete mode 100644 matrix.go delete mode 100644 messagetracking.go delete mode 100644 metrics.go delete mode 100644 portal.go delete mode 100644 provisioning.go delete mode 100644 puppet.go delete mode 100644 urlpreview.go delete mode 100644 user.go diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fc9fc7c..2fa759a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,3 @@ include: - project: 'mautrix/ci' - file: '/gov2.yml' - -variables: - BINARY_NAME_V2: mautrix-whatsapp + file: '/gov2-as-default.yml' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d11d187..3100fa7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,21 +17,10 @@ repos: - "maunium.net/go/mautrix-whatsapp" - "-w" - id: go-vet-repo-mod - # TODO switch to standard staticcheck after deleting old bridge - #- 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/...)' + - id: go-staticcheck-repo-mod - repo: https://github.com/beeper/pre-commit-go rev: v0.3.1 hooks: - id: zerolog-ban-msgf - # TODO enable after deleting old bridge - #- id: zerolog-use-stringer + - id: zerolog-use-stringer diff --git a/CHANGELOG.md b/CHANGELOG.md index c03ba7d..fdf1019 100644 --- a/CHANGELOG.md +++ b/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) * Added support for receiving Meta AI messages. diff --git a/Dockerfile b/Dockerfile index 5931d5e..9ba8093 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 COPY . /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 \ GID=1337 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/example-config.yaml /opt/mautrix-whatsapp/example-config.yaml +COPY --from=builder /build/mautrix-whatsapp /usr/bin/mautrix-whatsapp COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data diff --git a/Dockerfile.ci b/Dockerfile.ci index e08e6ca..c8fade7 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,14 +1,15 @@ -FROM alpine:3.19 +FROM alpine:3.20 ENV UID=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 COPY $EXECUTABLE /usr/bin/mautrix-whatsapp -COPY ./example-config.yaml /opt/mautrix-whatsapp/example-config.yaml COPY ./docker-run.sh /docker-run.sh +ENV BRIDGEV2=1 VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 4edc6ad..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -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 diff --git a/Dockerfile.v2.ci b/Dockerfile.v2.ci deleted file mode 100644 index c8fade7..0000000 --- a/Dockerfile.v2.ci +++ /dev/null @@ -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"] diff --git a/LICENSE.exceptions b/LICENSE.exceptions index b4a8012..0d851e8 100644 --- a/LICENSE.exceptions +++ b/LICENSE.exceptions @@ -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 the source code of mautrix-whatsapp remain publicly available under the terms 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. diff --git a/ROADMAP.md b/ROADMAP.md index 076153b..1af2eef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,19 +46,19 @@ * [x] Typing notifications * [x] Read receipts * [x] Admin/superadmin status - * [ ] Membership actions - * [ ] Invite - * [ ] Join - * [ ] Leave - * [ ] Kick - * [ ] Group metadata changes - * [ ] Title - * [ ] Avatar - * [ ] Description - * [ ] Initial group metadata - * [ ] User metadata changes - * [ ] Display name - * [ ] Avatar + * [x] Membership actions + * [x] Invite + * [x] Join + * [x] Leave + * [x] Kick + * [x] Group metadata changes + * [x] Title + * [x] Avatar + * [x] Description + * [x] Initial group metadata + * [x] User metadata changes + * [x] Display name + * [x] Avatar * [x] Initial user metadata * [x] Display name * [x] Avatar diff --git a/analytics.go b/analytics.go deleted file mode 100644 index 167b8b1..0000000 --- a/analytics.go +++ /dev/null @@ -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 . - -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") - } - }() -} diff --git a/backfillqueue.go b/backfillqueue.go deleted file mode 100644 index 2ca4e7e..0000000 --- a/backfillqueue.go +++ /dev/null @@ -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 . - -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") - } - } -} diff --git a/bridgestate.go b/bridgestate.go deleted file mode 100644 index 20fd581..0000000 --- a/bridgestate.go +++ /dev/null @@ -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 . - -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) - } -} diff --git a/build-v2.sh b/build-v2.sh deleted file mode 100755 index aeab27d..0000000 --- a/build-v2.sh +++ /dev/null @@ -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 "$@" diff --git a/build.sh b/build.sh index 2409c5b..aeab27d 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,4 @@ #!/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 "$@" diff --git a/commands.go b/commands.go deleted file mode 100644 index 8d7908e..0000000 --- a/commands.go +++ /dev/null @@ -1,1248 +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 . - -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "html" - "math" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/rs/zerolog" - "github.com/skip2/go-qrcode" - "github.com/tidwall/gjson" - - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/appstate" - "go.mau.fi/whatsmeow/types" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type WrappedCommandEvent struct { - *commands.Event - Bridge *WABridge - User *User - Portal *Portal -} - -func (br *WABridge) RegisterCommands() { - proc := br.CommandProcessor.(*commands.Processor) - proc.AddHandlers( - cmdSetRelay, - cmdUnsetRelay, - cmdInviteLink, - cmdResolveLink, - cmdJoin, - cmdAccept, - cmdCreate, - cmdLogin, - cmdLogout, - cmdTogglePresence, - cmdDeleteSession, - cmdReconnect, - cmdDisconnect, - cmdPing, - cmdDeletePortal, - cmdDeleteAllPortals, - cmdList, - cmdSearch, - cmdOpen, - cmdPM, - cmdSync, - cmdDisappearingTimer, - ) -} - -func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { - return func(ce *commands.Event) { - user := ce.User.(*User) - var portal *Portal - if ce.Portal != nil { - portal = ce.Portal.(*Portal) - } - br := ce.Bridge.Child.(*WABridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) - } -} - -var ( - HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11} - HelpSectionCreatingPortals = commands.HelpSection{Name: "Creating portals", Order: 15} - HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} - HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25} - HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30} -) - -var cmdSetRelay = &commands.FullHandler{ - Func: wrapCommand(fnSetRelay), - Name: "set-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Relay messages in this room through your WhatsApp account.", - }, - RequiresPortal: true, - RequiresLogin: true, -} - -func fnSetRelay(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Bridge.Relay.Enabled { - ce.Reply("Relay mode is not enabled on this instance of the bridge") - } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { - ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = ce.User.MXID - err := ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal after setting relay user") - } - ce.Reply("Messages from non-logged-in users in this room will now be bridged through your WhatsApp account") - } -} - -var cmdUnsetRelay = &commands.FullHandler{ - Func: wrapCommand(fnUnsetRelay), - Name: "unset-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Stop relaying messages in this room.", - }, - RequiresPortal: true, -} - -func fnUnsetRelay(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Bridge.Relay.Enabled { - ce.Reply("Relay mode is not enabled on this instance of the bridge") - } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { - ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = "" - err := ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal after clearing relay user") - } - ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") - } -} - -var cmdInviteLink = &commands.FullHandler{ - Func: wrapCommand(fnInviteLink), - Name: "invite-link", - Help: commands.HelpMeta{ - Section: HelpSectionInvites, - Description: "Get an invite link to the current group chat, optionally regenerating the link and revoking the old link.", - Args: "[--reset]", - }, - RequiresPortal: true, - RequiresLogin: true, -} - -func fnInviteLink(ce *WrappedCommandEvent) { - reset := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--reset" - if ce.Portal.IsPrivateChat() { - ce.Reply("Can't get invite link to private chat") - } else if ce.Portal.IsBroadcastList() { - ce.Reply("Can't get invite link to broadcast list") - } else if link, err := ce.User.Client.GetGroupInviteLink(ce.Portal.Key.JID, reset); err != nil { - ce.Reply("Failed to get invite link: %v", err) - } else { - ce.Reply(link) - } -} - -var cmdResolveLink = &commands.FullHandler{ - Func: wrapCommand(fnResolveLink), - Name: "resolve-link", - Help: commands.HelpMeta{ - Section: HelpSectionInvites, - Description: "Resolve a WhatsApp group invite or business message link.", - Args: "<_group, contact, or message link_>", - }, - RequiresLogin: true, -} - -func fnResolveLink(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `resolve-link `") - return - } - if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { - group, err := ce.User.Client.GetGroupInfoFromLink(ce.Args[0]) - if err != nil { - ce.Reply("Failed to get group info: %v", err) - return - } - ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID) - } else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) { - target, err := ce.User.Client.ResolveBusinessMessageLink(ce.Args[0]) - if err != nil { - ce.Reply("Failed to get business info: %v", err) - return - } - message := "" - if len(target.Message) > 0 { - parts := strings.Split(target.Message, "\n") - for i, part := range parts { - parts[i] = "> " + html.EscapeString(part) - } - message = fmt.Sprintf(" The following prefilled message is attached:\n\n%s", strings.Join(parts, "\n")) - } - ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message) - } else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) { - target, err := ce.User.Client.ResolveContactQRLink(ce.Args[0]) - if err != nil { - ce.Reply("Failed to get contact info: %v", err) - return - } - if target.PushName != "" { - ce.Reply("That link points at %s (+%s)", target.PushName, target.JID.User) - } else { - ce.Reply("That link points at +%s", target.JID.User) - } - } else { - ce.Reply("That doesn't look like a group invite link nor a business message link.") - } -} - -var cmdJoin = &commands.FullHandler{ - Func: wrapCommand(fnJoin), - Name: "join", - Help: commands.HelpMeta{ - Section: HelpSectionInvites, - Description: "Join a group chat with an invite link.", - Args: "<_invite link_>", - }, - RequiresLogin: true, -} - -func fnJoin(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `join `") - return - } - - if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { - jid, err := ce.User.Client.JoinGroupWithLink(ce.Args[0]) - if err != nil { - ce.Reply("Failed to join group: %v", err) - return - } - ce.ZLog.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link") - ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) - } else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) { - info, err := ce.User.Client.GetNewsletterInfoWithInvite(ce.Args[0]) - if err != nil { - ce.Reply("Failed to get channel info: %v", err) - return - } - err = ce.User.Client.FollowNewsletter(info.ID) - if err != nil { - ce.Reply("Failed to follow channel: %v", err) - return - } - ce.ZLog.Debug().Stringer("channel_jid", info.ID).Msg("User successfully followed WhatsApp channel with link") - ce.Reply("Successfully followed channel `%s`, the portal should be created momentarily", info.ID) - } else { - ce.Reply("That doesn't look like a WhatsApp invite link") - } -} - -func tryDecryptEvent(ce *WrappedCommandEvent, evt *event.Event) (json.RawMessage, error) { - var data json.RawMessage - if evt.Type != event.EventEncrypted { - data = evt.Content.VeryRaw - } else { - err := evt.Content.ParseRaw(evt.Type) - if err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { - return nil, err - } - decrypted, err := ce.Bridge.Crypto.Decrypt(ce.Ctx, evt) - if err != nil { - return nil, err - } - data = decrypted.Content.VeryRaw - } - return data, nil -} - -func parseInviteMeta(data json.RawMessage) (*InviteMeta, error) { - result := gjson.GetBytes(data, escapedInviteMetaField) - if !result.Exists() || !result.IsObject() { - return nil, nil - } - var meta InviteMeta - err := json.Unmarshal([]byte(result.Raw), &meta) - if err != nil { - return nil, nil - } - return &meta, nil -} - -var cmdAccept = &commands.FullHandler{ - Func: wrapCommand(fnAccept), - Name: "accept", - Help: commands.HelpMeta{ - Section: HelpSectionInvites, - Description: "Accept a group invite. This can only be used in reply to a group invite message.", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnAccept(ce *WrappedCommandEvent) { - if len(ce.ReplyTo) == 0 { - ce.Reply("You must reply to a group invite message when using this command.") - } else if evt, err := ce.Portal.MainIntent().GetEvent(ce.Ctx, ce.RoomID, ce.ReplyTo); err != nil { - ce.ZLog.Err(err).Stringer("reply_to_mxid", ce.ReplyTo).Msg("Failed to get reply target event to handle !wa accept command") - ce.Reply("Failed to get reply event") - } else if rawContent, err := tryDecryptEvent(ce, evt); err != nil { - ce.ZLog.Err(err).Stringer("reply_to_mxid", ce.ReplyTo).Msg("Failed to decrypt reply target event to handle !wa accept command") - ce.Reply("Failed to decrypt reply event") - } else if meta, err := parseInviteMeta(rawContent); err != nil || meta == nil { - ce.Reply("That doesn't look like a group invite message.") - } else if meta.Inviter.User == ce.User.JID.User { - ce.Reply("You can't accept your own invites") - } else if err = ce.User.Client.JoinGroupWithInvite(meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil { - ce.Reply("Failed to accept group invite: %v", err) - } else { - ce.Reply("Successfully accepted the invite, the portal should be created momentarily") - } -} - -var cmdCreate = &commands.FullHandler{ - Func: wrapCommand(fnCreate), - Name: "create", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Create a WhatsApp group chat for the current Matrix room.", - }, - RequiresLogin: true, -} - -func fnCreate(ce *WrappedCommandEvent) { - if ce.Portal != nil { - ce.Reply("This is already a portal room") - return - } - - members, err := ce.Bot.JoinedMembers(ce.Ctx, ce.RoomID) - if err != nil { - ce.Reply("Failed to get room members: %v", err) - return - } - - var roomNameEvent event.RoomNameEventContent - err = ce.Bot.StateEvent(ce.Ctx, ce.RoomID, event.StateRoomName, "", &roomNameEvent) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - ce.ZLog.Err(err).Msg("Failed to get room name to create group") - ce.Reply("Failed to get room name") - return - } else if len(roomNameEvent.Name) == 0 { - ce.Reply("Please set a name for the room first") - return - } - - var encryptionEvent event.EncryptionEventContent - err = ce.Bot.StateEvent(ce.Ctx, ce.RoomID, event.StateEncryption, "", &encryptionEvent) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - ce.ZLog.Err(err).Msg("Failed to get room encryption status to create group") - ce.Reply("Failed to get room encryption status") - return - } - - var createEvent event.CreateEventContent - err = ce.Bot.StateEvent(ce.Ctx, ce.RoomID, event.StateCreate, "", &createEvent) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - ce.ZLog.Err(err).Msg("Failed to get room create event to create group") - ce.Reply("Failed to get room create event") - return - } - - var participants []types.JID - participantDedup := make(map[types.JID]bool) - participantDedup[ce.User.JID.ToNonAD()] = true - participantDedup[types.EmptyJID] = true - for userID := range members.Joined { - jid, ok := ce.Bridge.ParsePuppetMXID(userID) - if !ok { - user := ce.Bridge.GetUserByMXID(userID) - if user != nil && !user.JID.IsEmpty() { - jid = user.JID.ToNonAD() - } - } - if !participantDedup[jid] { - participantDedup[jid] = true - participants = append(participants, jid) - } - } - // TODO check m.space.parent to create rooms directly in communities - - messageID := ce.User.Client.GenerateMessageID() - ce.ZLog.Info(). - Str("room_name", roomNameEvent.Name). - Any("participants", participants). - Str("create_key", messageID). - Msg("Creating WhatsApp group for Matrix room") - ce.User.createKeyDedup = messageID - resp, err := ce.User.Client.CreateGroup(whatsmeow.ReqCreateGroup{ - CreateKey: messageID, - Name: roomNameEvent.Name, - Participants: participants, - GroupParent: types.GroupParent{ - IsParent: createEvent.Type == event.RoomTypeSpace, - }, - }) - if err != nil { - ce.Reply("Failed to create group: %v", err) - return - } - ce.ZLog.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("group_jid", resp.JID.String()) - }) - portal := ce.User.GetPortalByJID(resp.JID) - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if len(portal.MXID) != 0 { - ce.ZLog.Warn().Msg("Detected race condition in room creation") - // TODO race condition, clean up the old room - } - portal.MXID = ce.RoomID - portal.updateLogger() - portal.Name = roomNameEvent.Name - portal.IsParent = resp.IsParent - portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 - if !portal.Encrypted && ce.Bridge.Config.Bridge.Encryption.Default { - _, err = portal.MainIntent().SendStateEvent(ce.Ctx, portal.MXID, event.StateEncryption, "", portal.GetEncryptionEventContent()) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to enable encryption in room") - if errors.Is(err, mautrix.MForbidden) { - ce.Reply("I don't seem to have permission to enable encryption in this room.") - } else { - ce.Reply("Failed to enable encryption in room: %v", err) - } - } - portal.Encrypted = true - } - - err = portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal after creating group") - } - portal.UpdateBridgeInfo(ce.Ctx) - ce.User.createKeyDedup = "" - - ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) -} - -var cmdLogin = &commands.FullHandler{ - Func: wrapCommand(fnLogin), - Name: "login", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Link the bridge to your WhatsApp account as a web client. " + - "The phone number parameter is optional: if provided, the bridge will create a 8-character login code " + - "that can be used instead of the QR code.", - Args: "[_phone number_]", - }, -} - -var looksLikeAPhoneRegex = regexp.MustCompile(`^\+[0-9]+$`) - -func fnLogin(ce *WrappedCommandEvent) { - if ce.User.Session != nil { - if ce.User.IsConnected() { - ce.Reply("You're already logged in") - } else { - ce.Reply("You're already logged in. Perhaps you wanted to `reconnect`?") - } - return - } - - var phoneNumber string - if len(ce.Args) > 0 { - phoneNumber = strings.TrimSpace(strings.Join(ce.Args, " ")) - if !looksLikeAPhoneRegex.MatchString(phoneNumber) { - ce.Reply("When specifying a phone number, it must be provided in international format without spaces or other extra characters") - return - } - } - - qrChan, qrReceivedChan, err := ce.User.Login(context.Background()) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to start login") - ce.Reply("Failed to log in: %v", err) - return - } - - if phoneNumber != "" { - select { - case <-qrReceivedChan: - case <-time.After(5 * time.Second): - ce.ZLog.Warn().Msg("Didn't receive QR event within 5 seconds of starting login") - } - pairingCode, err := ce.User.Client.PairPhone(phoneNumber, true, whatsmeow.PairClientChrome, "Chrome (Linux)") - if err != nil { - ce.ZLog.Err(err).Msg("Failed to start phone code login") - ce.Reply("Failed to start phone code login: %v", err) - go ce.User.DeleteConnection() - return - } - ce.Reply("Scan the code below or enter the following code on your phone to log in: **%s**", pairingCode) - } - - var qrEventID id.EventID - for item := range qrChan { - switch item.Event { - case whatsmeow.QRChannelSuccess.Event: - jid := ce.User.Client.Store.ID - ce.Reply("Successfully logged in as +%s (device #%d)", jid.User, jid.Device) - case whatsmeow.QRChannelTimeout.Event: - ce.Reply("Login timed out. Please restart the login.") - case whatsmeow.QRChannelErrUnexpectedEvent.Event: - ce.Reply("Failed to log in: unexpected connection event from server") - case whatsmeow.QRChannelClientOutdated.Event: - ce.Reply("Failed to log in: outdated client. The bridge must be updated to continue.") - case whatsmeow.QRChannelScannedWithoutMultidevice.Event: - ce.Reply("Please enable the WhatsApp multidevice beta and scan the QR code again.") - case "error": - ce.Reply("Failed to log in: %v", item.Error) - case "code": - qrEventID = ce.User.sendQR(ce, item.Code, qrEventID) - } - } - if qrEventID != "" { - _, _ = ce.Bot.RedactEvent(ce.Ctx, ce.RoomID, qrEventID) - } -} - -func (user *User) sendQR(ce *WrappedCommandEvent, code string, prevEvent id.EventID) id.EventID { - url, ok := user.uploadQR(ce, code) - if !ok { - return prevEvent - } - content := event.MessageEventContent{ - MsgType: event.MsgImage, - Body: code, - URL: url.CUString(), - } - if len(prevEvent) != 0 { - content.SetEdit(prevEvent) - } - resp, err := ce.Bot.SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, &content) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to send edited QR code to user") - } else if len(prevEvent) == 0 { - prevEvent = resp.EventID - } - return prevEvent -} - -func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) { - qrCode, err := qrcode.Encode(code, qrcode.Low, 256) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to encode QR code") - ce.Reply("Failed to encode QR code: %v", err) - return id.ContentURI{}, false - } - - bot := user.bridge.AS.BotClient() - - resp, err := bot.UploadBytes(ce.Ctx, qrCode, "image/png") - if err != nil { - ce.ZLog.Err(err).Msg("Failed to upload QR code") - ce.Reply("Failed to upload QR code: %v", err) - return id.ContentURI{}, false - } - return resp.ContentURI, true -} - -var cmdLogout = &commands.FullHandler{ - Func: wrapCommand(fnLogout), - Name: "logout", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Unlink the bridge from your WhatsApp account.", - }, -} - -func fnLogout(ce *WrappedCommandEvent) { - if ce.User.Session == nil { - ce.Reply("You're not logged in.") - return - } else if !ce.User.IsLoggedIn() { - ce.Reply("You are not connected to WhatsApp. Use the `reconnect` command to reconnect, or `delete-session` to forget all login information.") - return - } - puppet := ce.Bridge.GetPuppetByJID(ce.User.JID) - puppet.ClearCustomMXID() - err := ce.User.Client.Logout() - if err != nil { - ce.ZLog.Err(err).Msg("Unknown error while logging out") - ce.Reply("Unknown error while logging out: %v", err) - return - } - ce.User.Session = nil - ce.User.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut}) - ce.User.DeleteConnection() - ce.User.DeleteSession(ce.Ctx) - ce.Reply("Logged out successfully.") -} - -var cmdTogglePresence = &commands.FullHandler{ - Func: wrapCommand(fnTogglePresence), - Name: "toggle-presence", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Toggle bridging of presence or read receipts.", - }, -} - -func fnTogglePresence(ce *WrappedCommandEvent) { - if ce.User.Session == nil { - ce.Reply("You're not logged in.") - return - } - customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) - if customPuppet == nil { - ce.Reply("You're not logged in with your Matrix account.") - return - } - customPuppet.EnablePresence = !customPuppet.EnablePresence - var newPresence types.Presence - if customPuppet.EnablePresence { - newPresence = types.PresenceAvailable - ce.Reply("Enabled presence bridging") - } else { - newPresence = types.PresenceUnavailable - ce.Reply("Disabled presence bridging") - } - if ce.User.IsLoggedIn() { - err := ce.User.Client.SendPresence(newPresence) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to send presence to WhatsApp") - } - } - err := customPuppet.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save puppet after toggling presence") - } -} - -var cmdDeleteSession = &commands.FullHandler{ - Func: wrapCommand(fnDeleteSession), - Name: "delete-session", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Delete session information and disconnect from WhatsApp without sending a logout request.", - }, -} - -func fnDeleteSession(ce *WrappedCommandEvent) { - if ce.User.Session == nil && ce.User.Client == nil { - ce.Reply("Nothing to purge: no session information stored and no active connection.") - return - } - ce.User.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut}) - ce.User.DeleteConnection() - ce.User.DeleteSession(ce.Ctx) - ce.Reply("Session information purged") -} - -var cmdReconnect = &commands.FullHandler{ - Func: wrapCommand(fnReconnect), - Name: "reconnect", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Reconnect to WhatsApp.", - }, -} - -func fnReconnect(ce *WrappedCommandEvent) { - if ce.User.Client == nil { - if ce.User.Session == nil { - ce.Reply("You're not logged into WhatsApp. Please log in first.") - } else { - ce.User.Connect() - ce.Reply("Started connecting to WhatsApp") - } - } else { - ce.User.DeleteConnection() - ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected}) - ce.User.Connect() - ce.Reply("Restarted connection to WhatsApp") - } -} - -var cmdDisconnect = &commands.FullHandler{ - Func: wrapCommand(fnDisconnect), - Name: "disconnect", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Disconnect from WhatsApp (without logging out).", - }, -} - -func fnDisconnect(ce *WrappedCommandEvent) { - if ce.User.Client == nil { - ce.Reply("You don't have a WhatsApp connection.") - return - } - ce.User.DeleteConnection() - ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") - ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected}) -} - -var cmdPing = &commands.FullHandler{ - Func: wrapCommand(fnPing), - Name: "ping", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Check your connection to WhatsApp.", - }, -} - -func fnPing(ce *WrappedCommandEvent) { - if ce.User.Session == nil { - if ce.User.Client != nil { - ce.Reply("Connected to WhatsApp, but not logged in.") - } else { - ce.Reply("You're not logged into WhatsApp.") - } - } else if ce.User.Client == nil || !ce.User.Client.IsConnected() { - ce.Reply("You're logged in as +%s (device #%d), but you don't have a WhatsApp connection.", ce.User.JID.User, ce.User.JID.Device) - } else { - ce.Reply("Logged in as +%s (device #%d), connection to WhatsApp OK (probably)", ce.User.JID.User, ce.User.JID.Device) - if !ce.User.PhoneRecentlySeen(false) { - ce.Reply("Phone hasn't been seen in %s", formatDisconnectTime(time.Now().Sub(ce.User.PhoneLastSeen))) - } - } -} - -func canDeletePortal(ce *WrappedCommandEvent, portal *Portal) bool { - if len(portal.MXID) == 0 { - return false - } - - members, err := portal.MainIntent().JoinedMembers(ce.Ctx, portal.MXID) - if err != nil { - ce.ZLog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to get joined members to check if portal can be deleted by user") - return false - } - for otherUser := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(otherUser) - if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == ce.User.MXID { - continue - } - user := portal.bridge.GetUserByMXID(otherUser) - if user != nil && user.Session != nil { - return false - } - } - return true -} - -var cmdDeletePortal = &commands.FullHandler{ - Func: wrapCommand(fnDeletePortal), - Name: "delete-portal", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.", - }, - RequiresPortal: true, -} - -func fnDeletePortal(ce *WrappedCommandEvent) { - if !ce.User.Admin && !canDeletePortal(ce, ce.Portal) { - ce.Reply("Only bridge admins can delete portals with other Matrix users") - return - } - - ce.ZLog.Info().Msg("User requested deletion of current portal") - ce.Portal.Delete(ce.Ctx) - ce.Portal.Cleanup(ce.Ctx, false) -} - -var cmdDeleteAllPortals = &commands.FullHandler{ - Func: wrapCommand(fnDeleteAllPortals), - Name: "delete-all-portals", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Delete all portals.", - }, -} - -func fnDeleteAllPortals(ce *WrappedCommandEvent) { - portals := ce.Bridge.GetAllPortals() - var portalsToDelete []*Portal - - if ce.User.Admin { - portalsToDelete = portals - } else { - portalsToDelete = portals[:0] - for _, portal := range portals { - if canDeletePortal(ce, portal) { - portalsToDelete = append(portalsToDelete, portal) - } - } - } - if len(portalsToDelete) == 0 { - ce.Reply("Didn't find any portals to delete") - return - } - - leave := func(portal *Portal) { - if len(portal.MXID) > 0 { - _, _ = portal.MainIntent().KickUser(ce.Ctx, portal.MXID, &mautrix.ReqKickUser{ - Reason: "Deleting portal", - UserID: ce.User.MXID, - }) - } - } - customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - intent := customPuppet.CustomIntent() - leave = func(portal *Portal) { - if len(portal.MXID) > 0 { - _, _ = intent.LeaveRoom(ce.Ctx, portal.MXID) - _, _ = intent.ForgetRoom(ce.Ctx, portal.MXID) - } - } - } - ce.Reply("Found %d portals, deleting...", len(portalsToDelete)) - for _, portal := range portalsToDelete { - portal.Delete(ce.Ctx) - leave(portal) - } - ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") - - go func() { - for _, portal := range portalsToDelete { - portal.Cleanup(ce.Ctx, false) - } - ce.Reply("Finished background cleanup of deleted portal rooms.") - }() -} - -func matchesQuery(str string, query string) bool { - if query == "" { - return true - } - return strings.Contains(strings.ToLower(str), query) -} - -func formatContacts(bridge *WABridge, input map[types.JID]types.ContactInfo, query string) (result []string) { - hasQuery := len(query) > 0 - for jid, contact := range input { - if len(contact.FullName) == 0 { - continue - } - puppet := bridge.GetPuppetByJID(jid) - pushName := contact.PushName - if len(pushName) == 0 { - pushName = contact.FullName - } - - if !hasQuery || matchesQuery(pushName, query) || matchesQuery(contact.FullName, query) || matchesQuery(jid.User, query) { - result = append(result, fmt.Sprintf("* %s / [%s](https://matrix.to/#/%s) - `+%s`", contact.FullName, pushName, puppet.MXID, jid.User)) - } - } - sort.Sort(sort.StringSlice(result)) - return -} - -func formatGroups(input []*types.GroupInfo, query string) (result []string) { - hasQuery := len(query) > 0 - for _, group := range input { - if !hasQuery || matchesQuery(group.GroupName.Name, query) || matchesQuery(group.JID.User, query) { - result = append(result, fmt.Sprintf("* %s - `%s`", group.GroupName.Name, group.JID.User)) - } - } - sort.Sort(sort.StringSlice(result)) - return -} - -var cmdList = &commands.FullHandler{ - Func: wrapCommand(fnList), - Name: "list", - Help: commands.HelpMeta{ - Section: HelpSectionMiscellaneous, - Description: "Get a list of all contacts and groups.", - Args: "<`contacts`|`groups`> [_page_] [_items per page_]", - }, - RequiresLogin: true, -} - -func fnList(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `list [page] [items per page]`") - return - } - mode := strings.ToLower(ce.Args[0]) - if mode[0] != 'g' && mode[0] != 'c' { - ce.Reply("**Usage:** `list [page] [items per page]`") - return - } - var err error - page := 1 - maxPerPage := 100 - if len(ce.Args) > 1 { - page, err = strconv.Atoi(ce.Args[1]) - if err != nil || page <= 0 { - ce.Reply("\"%s\" isn't a valid page number", ce.Args[1]) - return - } - } - if len(ce.Args) > 2 { - maxPerPage, err = strconv.Atoi(ce.Args[2]) - if err != nil || maxPerPage <= 0 { - ce.Reply("\"%s\" isn't a valid number of items per page", ce.Args[2]) - return - } else if maxPerPage > 400 { - ce.Reply("Warning: a high number of items per page may fail to send a reply") - } - } - - contacts := mode[0] == 'c' - typeName := "Groups" - var result []string - if contacts { - typeName = "Contacts" - contactList, err := ce.User.Client.Store.Contacts.GetAllContacts() - if err != nil { - ce.Reply("Failed to get contacts: %s", err) - return - } - result = formatContacts(ce.User.bridge, contactList, "") - } else { - groupList, err := ce.User.Client.GetJoinedGroups() - if err != nil { - ce.Reply("Failed to get groups: %s", err) - return - } - result = formatGroups(groupList, "") - } - - if len(result) == 0 { - ce.Reply("No %s found", strings.ToLower(typeName)) - return - } - pages := int(math.Ceil(float64(len(result)) / float64(maxPerPage))) - if (page-1)*maxPerPage >= len(result) { - if pages == 1 { - ce.Reply("There is only 1 page of %s", strings.ToLower(typeName)) - } else { - ce.Reply("There are %d pages of %s", pages, strings.ToLower(typeName)) - } - return - } - lastIndex := page * maxPerPage - if lastIndex > len(result) { - lastIndex = len(result) - } - result = result[(page-1)*maxPerPage : lastIndex] - ce.Reply("### %s (page %d of %d)\n\n%s", typeName, page, pages, strings.Join(result, "\n")) -} - -var cmdSearch = &commands.FullHandler{ - Func: wrapCommand(fnSearch), - Name: "search", - Help: commands.HelpMeta{ - Section: HelpSectionMiscellaneous, - Description: "Search for contacts or groups.", - Args: "<_query_>", - }, - RequiresLogin: true, -} - -func fnSearch(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `search `") - return - } - - contactList, err := ce.User.Client.Store.Contacts.GetAllContacts() - if err != nil { - ce.Reply("Failed to get contacts: %s", err) - return - } - groupList, err := ce.User.Client.GetJoinedGroups() - if err != nil { - ce.Reply("Failed to get groups: %s", err) - return - } - - query := strings.ToLower(strings.TrimSpace(strings.Join(ce.Args, " "))) - formattedContacts := strings.Join(formatContacts(ce.User.bridge, contactList, query), "\n") - formattedGroups := strings.Join(formatGroups(groupList, query), "\n") - - result := make([]string, 0, 2) - if len(formattedContacts) > 0 { - result = append(result, "### Contacts\n\n"+formattedContacts) - } - if len(formattedGroups) > 0 { - result = append(result, "### Groups\n\n"+formattedGroups) - } - - if len(result) == 0 { - ce.Reply("No contacts or groups found") - return - } - - ce.Reply(strings.Join(result, "\n\n")) -} - -var cmdOpen = &commands.FullHandler{ - Func: wrapCommand(fnOpen), - Name: "open", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Open a group chat portal.", - Args: "<_group JID_>", - }, - RequiresLogin: true, -} - -func fnOpen(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `open `") - return - } - - var jid types.JID - if strings.ContainsRune(ce.Args[0], '@') { - jid, _ = types.ParseJID(ce.Args[0]) - } else { - jid = types.NewJID(ce.Args[0], types.GroupServer) - } - if (jid.Server != types.GroupServer && jid.Server != types.NewsletterServer) || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) { - ce.Reply("That does not look like a group JID") - return - } - - var err error - var groupInfo *types.GroupInfo - var newsletterMetadata *types.NewsletterMetadata - switch jid.Server { - case types.GroupServer: - groupInfo, err = ce.User.Client.GetGroupInfo(jid) - if err != nil { - ce.Reply("Failed to get group info: %v", err) - return - } - jid = groupInfo.JID - case types.NewsletterServer: - newsletterMetadata, err = ce.User.Client.GetNewsletterInfo(jid) - if err != nil { - ce.Reply("Failed to get channel info: %v", err) - return - } - jid = newsletterMetadata.ID - } - ce.ZLog.Debug().Stringer("chat_jid", jid).Msg("Importing chat for user") - portal := ce.User.GetPortalByJID(jid) - if len(portal.MXID) > 0 { - portal.UpdateMatrixRoom(ce.Ctx, ce.User, groupInfo, newsletterMetadata) - ce.Reply("Portal room synced.") - } else { - err = portal.CreateMatrixRoom(ce.Ctx, ce.User, groupInfo, newsletterMetadata, true, true) - if err != nil { - ce.Reply("Failed to create room: %v", err) - } else { - ce.Reply("Portal room created.") - } - } -} - -var cmdPM = &commands.FullHandler{ - Func: wrapCommand(fnPM), - Name: "pm", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Open a private chat with the given phone number.", - Args: "<_international phone number_>", - }, - RequiresLogin: true, -} - -func fnPM(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `pm `") - return - } - - user := ce.User - - number := strings.Join(ce.Args, "") - resp, err := ce.User.Client.IsOnWhatsApp([]string{number}) - if err != nil { - ce.Reply("Failed to check if user is on WhatsApp: %v", err) - return - } else if len(resp) == 0 { - ce.Reply("Didn't get a response to checking if the user is on WhatsApp") - return - } - targetUser := resp[0] - if !targetUser.IsIn { - ce.Reply("The server said +%s is not on WhatsApp", targetUser.JID.User) - return - } - - portal, puppet, justCreated, err := user.StartPM(ce.Ctx, targetUser.JID, "manual PM command") - if err != nil { - ce.Reply("Failed to create portal room: %v", err) - } else if !justCreated { - ce.Reply("You already have a private chat portal with +%s at [%s](https://matrix.to/#/%s)", puppet.JID.User, puppet.Displayname, portal.MXID) - } else { - ce.Reply("Created portal room with +%s and invited you to it.", puppet.JID.User) - } -} - -var cmdSync = &commands.FullHandler{ - Func: wrapCommand(fnSync), - Name: "sync", - Help: commands.HelpMeta{ - Section: HelpSectionMiscellaneous, - Description: "Synchronize data from WhatsApp.", - Args: " [--contact-avatars] [--create-portals]", - }, - RequiresLogin: true, -} - -func fnSync(ce *WrappedCommandEvent) { - args := strings.ToLower(strings.Join(ce.Args, " ")) - contacts := strings.Contains(args, "contacts") - appState := strings.Contains(args, "appstate") - space := strings.Contains(args, "space") - groups := strings.Contains(args, "groups") || space - if !contacts && !appState && !space && !groups { - ce.Reply("**Usage:** `sync [--contact-avatars] [--create-portals]`") - return - } - createPortals := strings.Contains(args, "--create-portals") - contactAvatars := strings.Contains(args, "--contact-avatars") - if contactAvatars && (!contacts || appState) { - ce.Reply("`--contact-avatars` can only be used with `sync contacts`") - return - } - if createPortals && !groups { - ce.Reply("`--create-portals` can only be used with `sync groups`") - return - } - - if appState { - for _, name := range appstate.AllPatchNames { - err := ce.User.Client.FetchAppState(name, true, false) - if errors.Is(err, appstate.ErrKeyNotFound) { - ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err) - return - } else if err != nil { - ce.Reply("Error syncing app state %s: %v", name, err) - } else if name == appstate.WAPatchCriticalUnblockLow { - ce.Reply("Synced app state %s, contact sync running in background", name) - } else { - ce.Reply("Synced app state %s", name) - } - } - } else if contacts { - err := ce.User.ResyncContacts(contactAvatars) - if err != nil { - ce.Reply("Error resyncing contacts: %v", err) - } else { - ce.Reply("Resynced contacts") - } - } - if space { - if !ce.Bridge.Config.Bridge.PersonalFilteringSpaces { - ce.Reply("Personal filtering spaces are not enabled on this instance of the bridge") - return - } - keys, err := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ce.Ctx, ce.User.JID) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to get list of private chats not in space") - ce.Reply("Failed to get list of private chats not in space") - return - } - count := 0 - for _, key := range keys { - portal := ce.Bridge.GetPortalByJID(key) - portal.addToPersonalSpace(ce.Ctx, ce.User) - count++ - } - plural := "s" - if count == 1 { - plural = "" - } - ce.Reply("Added %d DM room%s to space", count, plural) - } - if groups { - err := ce.User.ResyncGroups(createPortals) - if err != nil { - ce.Reply("Error resyncing groups: %v", err) - } else { - ce.Reply("Resynced groups") - } - } -} - -var cmdDisappearingTimer = &commands.FullHandler{ - Func: wrapCommand(fnDisappearingTimer), - Name: "disappearing-timer", - Aliases: []string{"disappear-timer"}, - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Set future messages in the room to disappear after the given time.", - Args: "", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnDisappearingTimer(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `disappearing-timer `") - return - } - duration, ok := whatsmeow.ParseDisappearingTimerString(ce.Args[0]) - if !ok { - ce.Reply("Invalid timer '%s'", ce.Args[0]) - return - } - prevExpirationTime := ce.Portal.ExpirationTime - ce.Portal.ExpirationTime = uint32(duration.Seconds()) - err := ce.User.Client.SetDisappearingTimer(ce.Portal.Key.JID, duration) - if err != nil { - ce.Reply("Failed to set disappearing timer: %v", err) - ce.Portal.ExpirationTime = prevExpirationTime - return - } - err = ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal after setting disappearing timer") - } - ce.React("✅") -} diff --git a/config/bridge.go b/config/bridge.go deleted file mode 100644 index d74951a..0000000 --- a/config/bridge.go +++ /dev/null @@ -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 . - -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 -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 4069195..0000000 --- a/config/config.go +++ /dev/null @@ -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 . - -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 -} diff --git a/config/upgrade.go b/config/upgrade.go deleted file mode 100644 index d879d77..0000000 --- a/config/upgrade.go +++ /dev/null @@ -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 . - -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"}, -} diff --git a/custompuppet.go b/custompuppet.go deleted file mode 100644 index 8fccc57..0000000 --- a/custompuppet.go +++ /dev/null @@ -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 . - -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") - } -} diff --git a/database/backfillqueue.go b/database/backfillqueue.go deleted file mode 100644 index bf3bd99..0000000 --- a/database/backfillqueue.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/backfillstate.go b/database/backfillstate.go deleted file mode 100644 index 6ad49b1..0000000 --- a/database/backfillstate.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/database.go b/database/database.go deleted file mode 100644 index 5822c56..0000000 --- a/database/database.go +++ /dev/null @@ -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 . - -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 -} diff --git a/database/disappearingmessage.go b/database/disappearingmessage.go deleted file mode 100644 index 31eef09..0000000 --- a/database/disappearingmessage.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/historysync.go b/database/historysync.go deleted file mode 100644 index a5b57e4..0000000 --- a/database/historysync.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/mediabackfillrequest.go b/database/mediabackfillrequest.go deleted file mode 100644 index aa74b9e..0000000 --- a/database/mediabackfillrequest.go +++ /dev/null @@ -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 . - -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()...) -} diff --git a/database/message.go b/database/message.go deleted file mode 100644 index 63e9727..0000000 --- a/database/message.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/polloption.go b/database/polloption.go deleted file mode 100644 index 03d8566..0000000 --- a/database/polloption.go +++ /dev/null @@ -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 . - -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 }, - ) -} diff --git a/database/portal.go b/database/portal.go deleted file mode 100644 index 72156db..0000000 --- a/database/portal.go +++ /dev/null @@ -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 . - -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) - }) -} diff --git a/database/puppet.go b/database/puppet.go deleted file mode 100644 index 8b160b8..0000000 --- a/database/puppet.go +++ /dev/null @@ -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 . - -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()...) -} diff --git a/database/reaction.go b/database/reaction.go deleted file mode 100644 index b74ead5..0000000 --- a/database/reaction.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql deleted file mode 100644 index dd799f1..0000000 --- a/database/upgrades/00-latest-revision.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/36-phone-last-seen-ts.sql b/database/upgrades/36-phone-last-seen-ts.sql deleted file mode 100644 index 21e5b70..0000000 --- a/database/upgrades/36-phone-last-seen-ts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v36: Store approximate last seen timestamp of the main device - -ALTER TABLE "user" ADD COLUMN phone_last_seen BIGINT; diff --git a/database/upgrades/37-message-error-string.sql b/database/upgrades/37-message-error-string.sql deleted file mode 100644 index 88a647c..0000000 --- a/database/upgrades/37-message-error-string.sql +++ /dev/null @@ -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; diff --git a/database/upgrades/38-phone-ping-ts.sql b/database/upgrades/38-phone-ping-ts.sql deleted file mode 100644 index 77e24ed..0000000 --- a/database/upgrades/38-phone-ping-ts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v38: Store timestamp for previous phone ping - -ALTER TABLE "user" ADD COLUMN phone_last_pinged BIGINT; diff --git a/database/upgrades/39-reactions.sql b/database/upgrades/39-reactions.sql deleted file mode 100644 index d90c6b7..0000000 --- a/database/upgrades/39-reactions.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/40-prioritized-backfill.sql b/database/upgrades/40-prioritized-backfill.sql deleted file mode 100644 index b435e61..0000000 --- a/database/upgrades/40-prioritized-backfill.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/41-historysync-store.sql b/database/upgrades/41-historysync-store.sql deleted file mode 100644 index 0a00cb7..0000000 --- a/database/upgrades/41-historysync-store.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/42-backfillqueue-type-order.sql b/database/upgrades/42-backfillqueue-type-order.sql deleted file mode 100644 index a45834d..0000000 --- a/database/upgrades/42-backfillqueue-type-order.sql +++ /dev/null @@ -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; diff --git a/database/upgrades/43-media-backfill-requests.sql b/database/upgrades/43-media-backfill-requests.sql deleted file mode 100644 index 7c13803..0000000 --- a/database/upgrades/43-media-backfill-requests.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/44-user-timezone.sql b/database/upgrades/44-user-timezone.sql deleted file mode 100644 index 1a35fa9..0000000 --- a/database/upgrades/44-user-timezone.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v44: Add timezone column for users - -ALTER TABLE "user" ADD COLUMN timezone TEXT; diff --git a/database/upgrades/45-backfillqueue-dispatch-time.sql b/database/upgrades/45-backfillqueue-dispatch-time.sql deleted file mode 100644 index 1c02b12..0000000 --- a/database/upgrades/45-backfillqueue-dispatch-time.sql +++ /dev/null @@ -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; diff --git a/database/upgrades/46-history-sync-message-added-timestamp.sql b/database/upgrades/46-history-sync-message-added-timestamp.sql deleted file mode 100644 index 4cf2cfc..0000000 --- a/database/upgrades/46-history-sync-message-added-timestamp.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v46: Add inserted time to history sync message - -ALTER TABLE history_sync_message ADD COLUMN inserted_time TIMESTAMP; diff --git a/database/upgrades/47-room-backfill-state.sql b/database/upgrades/47-room-backfill-state.sql deleted file mode 100644 index 18d3efe..0000000 --- a/database/upgrades/47-room-backfill-state.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/48-crypto-store-handling-split.sql b/database/upgrades/48-crypto-store-handling-split.sql deleted file mode 100644 index 2ef9da1..0000000 --- a/database/upgrades/48-crypto-store-handling-split.sql +++ /dev/null @@ -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); diff --git a/database/upgrades/49-backfill-state-timestamp-bigint.sql b/database/upgrades/49-backfill-state-timestamp-bigint.sql deleted file mode 100644 index fb32a53..0000000 --- a/database/upgrades/49-backfill-state-timestamp-bigint.sql +++ /dev/null @@ -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$ diff --git a/database/upgrades/50-puppet-background-sync.sql b/database/upgrades/50-puppet-background-sync.sql deleted file mode 100644 index 86f9be0..0000000 --- a/database/upgrades/50-puppet-background-sync.sql +++ /dev/null @@ -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<>''; diff --git a/database/upgrades/51-portal-background-sync.sql b/database/upgrades/51-portal-background-sync.sql deleted file mode 100644 index ba0bd0b..0000000 --- a/database/upgrades/51-portal-background-sync.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v51: Add last sync timestamp for portals too - -ALTER TABLE portal ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0; diff --git a/database/upgrades/52-communities.sql b/database/upgrades/52-communities.sql deleted file mode 100644 index 51110e8..0000000 --- a/database/upgrades/52-communities.sql +++ /dev/null @@ -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; diff --git a/database/upgrades/53-community-index.sql b/database/upgrades/53-community-index.sql deleted file mode 100644 index b6d49a6..0000000 --- a/database/upgrades/53-community-index.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v53: Add index to make querying by community faster -CREATE INDEX portal_parent_group_idx ON portal(parent_group); diff --git a/database/upgrades/54-poll-option-id-map.sql b/database/upgrades/54-poll-option-id-map.sql deleted file mode 100644 index 2593fc3..0000000 --- a/database/upgrades/54-poll-option-id-map.sql +++ /dev/null @@ -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 -); diff --git a/database/upgrades/55-add-contact-info.sql b/database/upgrades/55-add-contact-info.sql deleted file mode 100644 index db85a13..0000000 --- a/database/upgrades/55-add-contact-info.sql +++ /dev/null @@ -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; diff --git a/database/upgrades/56-message-sender-mxid.sql b/database/upgrades/56-message-sender-mxid.sql deleted file mode 100644 index 43ba717..0000000 --- a/database/upgrades/56-message-sender-mxid.sql +++ /dev/null @@ -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 ''; diff --git a/database/upgrades/57-message-timestamp-index.sql b/database/upgrades/57-message-timestamp-index.sql deleted file mode 100644 index c6ebe13..0000000 --- a/database/upgrades/57-message-timestamp-index.sql +++ /dev/null @@ -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); diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go deleted file mode 100644 index 5ad0f53..0000000 --- a/database/upgrades/upgrades.go +++ /dev/null @@ -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 . - -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) -} diff --git a/database/user.go b/database/user.go deleted file mode 100644 index 1d5fa8e..0000000 --- a/database/user.go +++ /dev/null @@ -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 . - -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 -} diff --git a/database/userportal.go b/database/userportal.go deleted file mode 100644 index ff1a96a..0000000 --- a/database/userportal.go +++ /dev/null @@ -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 . - -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. - -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") - } -} diff --git a/docker-run.sh b/docker-run.sh index 7b2d551..7ae6a26 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -15,11 +15,7 @@ function fixperms { } if [[ ! -f /data/config.yaml ]]; then - if [[ "$BRIDGEV2" == "1" ]]; then - /usr/bin/mautrix-whatsapp -c /data/config.yaml -e - else - cp /opt/mautrix-whatsapp/example-config.yaml /data/config.yaml - fi + /usr/bin/mautrix-whatsapp -c /data/config.yaml -e echo "Didn't find a config file." echo "Copied default config file to /data/config.yaml" echo "Modify that config file to your liking." diff --git a/example-config.yaml b/example-config.yaml deleted file mode 100644 index 25ddf94..0000000 --- a/example-config.yaml +++ /dev/null @@ -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:?_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: "{{ .Sender.Displayname }}: {{ .Message }}" - m.notice: "{{ .Sender.Displayname }}: {{ .Message }}" - m.emote: "* {{ .Sender.Displayname }} {{ .Message }}" - m.file: "{{ .Sender.Displayname }} sent a file" - m.image: "{{ .Sender.Displayname }} sent an image" - m.audio: "{{ .Sender.Displayname }} sent an audio file" - m.video: "{{ .Sender.Displayname }} sent a video" - m.location: "{{ .Sender.Displayname }} 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 diff --git a/formatting.go b/formatting.go deleted file mode 100644 index 9f39020..0000000 --- a/formatting.go +++ /dev/null @@ -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 . - -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$2$3", - boldRegex: "$1$2$3", - strikethroughRegex: "$1$2$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("
%s
", str) - } - return fmt.Sprintf("%s", 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(`%s`, 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(`%s`, 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", "
") - 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) -} diff --git a/go.mod b/go.mod index 247c845..9d456c5 100644 --- a/go.mod +++ b/go.mod @@ -8,18 +8,13 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 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/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/webp v0.1.0 go.mau.fi/whatsmeow v0.0.0-20240927134544-69ba055bef0f golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 golang.org/x/image v0.20.0 golang.org/x/net v0.29.0 - golang.org/x/sync v0.8.0 google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.21.1-0.20240927113633-d1e5b09d972b @@ -27,20 +22,17 @@ require ( require ( 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/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // 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/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/rogpeppe/go-internal v1.10.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/pretty v1.2.1 // 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/zeroconfig v0.1.3 // 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/text v0.18.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 64a8d35..c832ab0 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 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/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/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= 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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= diff --git a/historysync.go b/historysync.go deleted file mode 100644 index a8a2c58..0000000 --- a/historysync.go +++ /dev/null @@ -1,1024 +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 . - -package main - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "strings" - "time" - - "github.com/rs/zerolog" - waProto "go.mau.fi/whatsmeow/binary/proto" - "go.mau.fi/whatsmeow/types" - - "go.mau.fi/util/variationselector" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix-whatsapp/config" - "maunium.net/go/mautrix-whatsapp/database" -) - -// region User history sync handling - -type wrappedInfo struct { - *types.MessageInfo - Type database.MessageType - Error database.MessageErrorType - - SenderMXID id.UserID - - ReactionTarget types.MessageID - - MediaKey []byte - - ExpirationStart time.Time - ExpiresIn time.Duration -} - -func (user *User) handleHistorySyncsLoop() { - if !user.bridge.Config.Bridge.HistorySync.Backfill { - return - } - - batchSend := user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) - if batchSend { - // Start the backfill queue. - user.BackfillQueue = &BackfillQueue{ - BackfillQuery: user.bridge.DB.BackfillQueue, - reCheckChannels: []chan bool{}, - } - - forwardAndImmediate := []database.BackfillType{database.BackfillImmediate, database.BackfillForward} - - // Immediate backfills can be done in parallel - for i := 0; i < user.bridge.Config.Bridge.HistorySync.Immediate.WorkerCount; i++ { - go user.HandleBackfillRequestsLoop(forwardAndImmediate, []database.BackfillType{}) - } - - // Deferred backfills should be handled synchronously so as not to - // overload the homeserver. Users can configure their backfill stages - // to be more or less aggressive with backfilling at this stage. - go user.HandleBackfillRequestsLoop([]database.BackfillType{database.BackfillDeferred}, forwardAndImmediate) - } - - if user.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia && - user.bridge.Config.Bridge.HistorySync.MediaRequests.RequestMethod == config.MediaRequestMethodLocalTime { - go user.dailyMediaRequestLoop() - } - - // Always save the history syncs for the user. If they want to enable - // backfilling in the future, we will have it in the database. - for { - select { - case evt := <-user.historySyncs: - if evt == nil { - return - } - user.storeHistorySync(evt.Data) - case <-user.enqueueBackfillsTimer.C: - if batchSend { - user.enqueueAllBackfills() - } else { - user.backfillAll() - } - } - } -} - -const EnqueueBackfillsDelay = 30 * time.Second - -func (user *User) enqueueAllBackfills() { - log := user.zlog.With(). - Str("method", "User.enqueueAllBackfills"). - Logger() - ctx := log.WithContext(context.TODO()) - nMostRecent, err := user.bridge.DB.HistorySync.GetRecentConversations(ctx, user.MXID, user.bridge.Config.Bridge.HistorySync.MaxInitialConversations) - if err != nil { - log.Err(err).Msg("Failed to get recent history sync conversations from database") - return - } else if len(nMostRecent) == 0 { - return - } - log.Info(). - Int("chat_count", len(nMostRecent)). - Msg("Enqueueing backfills for recent chats in history sync") - // Find the portals for all the conversations. - portals := make([]*Portal, 0, len(nMostRecent)) - for _, conv := range nMostRecent { - jid, err := types.ParseJID(conv.ConversationID) - if err != nil { - log.Err(err).Str("conversation_id", conv.ConversationID).Msg("Failed to parse chat JID in history sync") - continue - } - portals = append(portals, user.GetPortalByJID(jid)) - } - - user.EnqueueImmediateBackfills(ctx, portals) - user.EnqueueForwardBackfills(ctx, portals) - user.EnqueueDeferredBackfills(ctx, portals) - - // Tell the queue to check for new backfill requests. - user.BackfillQueue.ReCheck() -} - -func (user *User) backfillAll() { - log := user.zlog.With(). - Str("method", "User.backfillAll"). - Logger() - ctx := log.WithContext(context.TODO()) - conversations, err := user.bridge.DB.HistorySync.GetRecentConversations(ctx, user.MXID, -1) - if err != nil { - log.Err(err).Msg("Failed to get history sync conversations from database") - return - } else if len(conversations) == 0 { - return - } - log.Info(). - Int("conversation_count", len(conversations)). - Msg("Probably received all history sync blobs, now backfilling conversations") - limit := user.bridge.Config.Bridge.HistorySync.MaxInitialConversations - bridgedCount := 0 - // Find the portals for all the conversations. - for _, conv := range conversations { - jid, err := types.ParseJID(conv.ConversationID) - if err != nil { - log.Err(err). - Str("conversation_id", conv.ConversationID). - Msg("Failed to parse chat JID in history sync") - continue - } - portal := user.GetPortalByJID(jid) - if portal.MXID != "" { - log.Debug(). - Str("portal_jid", portal.Key.JID.String()). - Msg("Chat already has a room, deleting messages from database") - err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String()) - if err != nil { - log.Err(err).Str("portal_jid", portal.Key.JID.String()). - Msg("Failed to delete history sync conversation with existing portal from database") - } - bridgedCount++ - } else if hasMessages, err := user.bridge.DB.HistorySync.ConversationHasMessages(ctx, user.MXID, portal.Key); err != nil { - log.Err(err).Str("portal_jid", portal.Key.JID.String()).Msg("Failed to check if chat has messages in history sync") - } else if !hasMessages { - log.Debug().Str("portal_jid", portal.Key.JID.String()).Msg("Skipping chat with no messages in history sync") - err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String()) - if err != nil { - log.Err(err).Str("portal_jid", portal.Key.JID.String()). - Msg("Failed to delete history sync conversation with no messages from database") - } - } else if limit < 0 || bridgedCount < limit { - bridgedCount++ - err = portal.CreateMatrixRoom(ctx, user, nil, nil, true, true) - if err != nil { - log.Err(err).Msg("Failed to create Matrix room for backfill") - } - } - } -} - -func (portal *Portal) legacyBackfill(ctx context.Context, user *User) { - defer portal.latestEventBackfillLock.Unlock() - // This should only be called from CreateMatrixRoom which locks latestEventBackfillLock before creating the room. - if portal.latestEventBackfillLock.TryLock() { - panic("legacyBackfill() called without locking latestEventBackfillLock") - } - log := zerolog.Ctx(ctx).With().Str("action", "legacy backfill").Logger() - ctx = log.WithContext(ctx) - conv, err := user.bridge.DB.HistorySync.GetConversation(ctx, user.MXID, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to get history sync conversation data for backfill") - return - } - messages, err := user.bridge.DB.HistorySync.GetMessagesBetween(ctx, user.MXID, portal.Key.JID.String(), nil, nil, portal.bridge.Config.Bridge.HistorySync.MessageCount) - if err != nil { - log.Err(err).Msg("Failed to get history sync messages for backfill") - return - } - log.Debug().Int("message_count", len(messages)).Msg("Got messages to backfill from database") - for i := len(messages) - 1; i >= 0; i-- { - msgEvt, err := user.Client.ParseWebMessage(portal.Key.JID, messages[i]) - if err != nil { - log.Warn().Err(err). - Int("msg_index", i). - Str("msg_id", messages[i].GetKey().GetId()). - Uint64("msg_time_seconds", messages[i].GetMessageTimestamp()). - Msg("Dropping historical message due to parse error") - continue - } - ctx := log.With(). - Str("message_id", msgEvt.Info.ID). - Stringer("message_sender", msgEvt.Info.Sender). - Logger(). - WithContext(ctx) - portal.handleMessage(ctx, user, msgEvt, true) - } - if conv != nil { - isUnread := conv.MarkedAsUnread || conv.UnreadCount > 0 - isTooOld := user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour)) - shouldMarkAsRead := !isUnread || isTooOld - if shouldMarkAsRead { - user.markSelfReadFull(ctx, portal) - } - } - log.Info().Msg("Backfill complete, deleting leftover messages from database") - err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String()) - if err != nil { - log.Err(err).Msg("Failed to delete history sync conversation from database after backfill") - } -} - -func (user *User) dailyMediaRequestLoop() { - log := user.zlog.With(). - Str("action", "daily media request loop"). - Logger() - ctx := log.WithContext(context.Background()) - - // Calculate when to do the first set of media retry requests - now := time.Now() - userTz, err := time.LoadLocation(user.Timezone) - tzIsInvalid := err != nil && user.Timezone != "" - var requestStartTime time.Time - if tzIsInvalid { - requestStartTime = now.Add(8 * time.Hour) - log.Warn().Msg("Invalid time zone, using static 8 hour start time") - } else { - if userTz == nil { - userTz = now.Local().Location() - } - tonightMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, userTz) - midnightOffset := time.Duration(user.bridge.Config.Bridge.HistorySync.MediaRequests.RequestLocalTime) * time.Minute - requestStartTime = tonightMidnight.Add(midnightOffset) - // If the request time for today has already happened, we need to start the - // request loop tomorrow instead. - if requestStartTime.Before(now) { - requestStartTime = requestStartTime.AddDate(0, 0, 1) - } - } - - // Wait to start the loop - log.Info().Time("start_loop_at", requestStartTime).Msg("Waiting until start time to do media retry requests") - time.Sleep(time.Until(requestStartTime)) - - for { - mediaBackfillRequests, err := user.bridge.DB.MediaBackfillRequest.GetMediaBackfillRequestsForUser(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to get media retry requests") - } else if len(mediaBackfillRequests) > 0 { - log.Info().Int("media_request_count", len(mediaBackfillRequests)).Msg("Sending media retry requests") - - // Send all the media backfill requests for the user at once - for _, req := range mediaBackfillRequests { - portal := user.GetPortalByJID(req.PortalKey.JID) - _, err = portal.requestMediaRetry(ctx, user, req.EventID, req.MediaKey) - if err != nil { - log.Err(err). - Stringer("portal_key", req.PortalKey). - Stringer("event_id", req.EventID). - Msg("Failed to send media retry request") - req.Status = database.MediaBackfillRequestStatusRequestFailed - req.Error = err.Error() - } else { - log.Debug(). - Stringer("portal_key", req.PortalKey). - Stringer("event_id", req.EventID). - Msg("Sent media retry request") - req.Status = database.MediaBackfillRequestStatusRequested - } - req.MediaKey = nil - err = req.Upsert(ctx) - if err != nil { - log.Err(err). - Stringer("portal_key", req.PortalKey). - Stringer("event_id", req.EventID). - Msg("Failed to save status of media retry request") - } - } - } - - // Wait for 24 hours before making requests again - time.Sleep(24 * time.Hour) - } -} - -func (user *User) backfillInChunks(ctx context.Context, req *database.BackfillTask, conv *database.HistorySyncConversation, portal *Portal) { - portal.backfillLock.Lock() - defer portal.backfillLock.Unlock() - log := zerolog.Ctx(ctx) - - if len(portal.MXID) > 0 && !user.bridge.AS.StateStore.IsInRoom(ctx, portal.MXID, user.MXID) { - portal.ensureUserInvited(ctx, user) - } - - backfillState, err := user.bridge.DB.BackfillState.GetBackfillState(ctx, user.MXID, portal.Key) - if backfillState == nil { - backfillState = user.bridge.DB.BackfillState.NewBackfillState(user.MXID, portal.Key) - } - err = backfillState.SetProcessingBatch(ctx, true) - if err != nil { - log.Err(err).Msg("Failed to mark batch as being processed") - } - defer func() { - err = backfillState.SetProcessingBatch(ctx, false) - if err != nil { - log.Err(err).Msg("Failed to mark batch as no longer being processed") - } - }() - - var timeEnd *time.Time - var forward, shouldMarkAsRead bool - portal.latestEventBackfillLock.Lock() - if req.BackfillType == database.BackfillForward { - // TODO this overrides the TimeStart set when enqueuing the backfill - // maybe the enqueue should instead include the prev event ID - lastMessage, err := portal.bridge.DB.Message.GetLastInChat(ctx, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to get newest message in chat") - return - } - start := lastMessage.Timestamp.Add(1 * time.Second) - req.TimeStart = &start - // Sending events at the end of the room (= latest events) - forward = true - } else { - firstMessage, err := portal.bridge.DB.Message.GetFirstInChat(ctx, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to get oldest message in chat") - return - } - if firstMessage != nil { - end := firstMessage.Timestamp.Add(-1 * time.Second) - timeEnd = &end - log.Debug(). - Time("oldest_message_ts", firstMessage.Timestamp). - Msg("Limiting backfill to messages older than oldest message") - } else { - // Portal is empty -> events are latest - forward = true - } - } - if !forward { - // We'll use normal batch sending, so no need to keep blocking new message processing - portal.latestEventBackfillLock.Unlock() - } else { - // This might involve sending events at the end of the room as non-historical events, - // make sure we don't process messages until this is done. - defer portal.latestEventBackfillLock.Unlock() - - isUnread := conv.MarkedAsUnread || conv.UnreadCount > 0 - isTooOld := user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour)) - shouldMarkAsRead = !isUnread || isTooOld - } - allMsgs, err := user.bridge.DB.HistorySync.GetMessagesBetween(ctx, user.MXID, conv.ConversationID, req.TimeStart, timeEnd, req.MaxTotalEvents) - - sendDisappearedNotice := false - // If expired messages are on, and a notice has not been sent to this chat - // about it having disappeared messages at the conversation timestamp, send - // a notice indicating so. - if len(allMsgs) == 0 && conv.EphemeralExpiration != nil && *conv.EphemeralExpiration > 0 { - lastMessage, err := portal.bridge.DB.Message.GetLastInChat(ctx, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to get last message in chat to check if disappeared notice should be sent") - } - if lastMessage == nil || conv.LastMessageTimestamp.After(lastMessage.Timestamp) { - sendDisappearedNotice = true - } - } - - if !sendDisappearedNotice && len(allMsgs) == 0 { - log.Debug().Msg("Not backfilling chat: no bridgeable messages found") - return - } - - if len(portal.MXID) == 0 { - log.Debug().Msg("Creating portal for chat as part of history sync handling") - err = portal.CreateMatrixRoom(ctx, user, nil, nil, true, false) - if err != nil { - log.Err(err).Msg("Failed to create room for chat during backfill") - return - } - } - - // Update the backfill status here after the room has been created. - portal.updateBackfillStatus(ctx, backfillState) - - if sendDisappearedNotice { - log.Debug().Time("last_message_time", conv.LastMessageTimestamp). - Msg("Sending notice that there are disappeared messages in the chat") - resp, err := portal.sendMessage(ctx, portal.MainIntent(), event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: portal.formatDisappearingMessageNotice(), - }, nil, conv.LastMessageTimestamp.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send disappeared messages notice event") - return - } - - msg := portal.bridge.DB.Message.New() - msg.Chat = portal.Key - msg.MXID = resp.EventID - msg.JID = types.MessageID(resp.EventID) - msg.Timestamp = conv.LastMessageTimestamp - msg.SenderMXID = portal.MainIntent().UserID - msg.Sent = true - msg.Type = database.MsgFake - err = msg.Insert(ctx) - if err != nil { - log.Err(err).Msg("Failed to save fake message entry for disappearing message timer in backfill") - } - user.markSelfReadFull(ctx, portal) - return - } - - log.Info(). - Int("message_count", len(allMsgs)). - Int("max_batch_events", req.MaxBatchEvents). - Msg("Backfilling messages") - toBackfill := allMsgs[0:] - for len(toBackfill) > 0 { - var msgs []*waProto.WebMessageInfo - if len(toBackfill) <= req.MaxBatchEvents || req.MaxBatchEvents < 0 { - msgs = toBackfill - toBackfill = nil - } else { - msgs = toBackfill[:req.MaxBatchEvents] - toBackfill = toBackfill[req.MaxBatchEvents:] - } - - if len(msgs) > 0 { - time.Sleep(time.Duration(req.BatchDelay) * time.Second) - log.Debug().Int("batch_message_count", len(msgs)).Msg("Backfilling message batch") - portal.backfill(ctx, user, msgs, forward, shouldMarkAsRead) - } - } - log.Debug().Int("message_count", len(allMsgs)).Msg("Finished backfilling messages in queue entry") - err = user.bridge.DB.HistorySync.DeleteMessages(ctx, user.MXID, conv.ConversationID, allMsgs) - if err != nil { - log.Err(err).Msg("Failed to delete history sync messages after backfilling") - } - - if req.TimeStart == nil { - // If the time start is nil, then there's no more history to backfill. - backfillState.BackfillComplete = true - - if conv.EndOfHistoryTransferType == waProto.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY { - // Since there are more messages on the phone, but we can't - // backfill any more of them, indicate that the last timestamp - // that we expect to be backfilled is the oldest one that was just - // backfilled. - backfillState.FirstExpectedTimestamp = allMsgs[len(allMsgs)-1].GetMessageTimestamp() - } else if conv.EndOfHistoryTransferType == waProto.Conversation_COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY { - // Since there are no more messages left on the phone, we've - // backfilled everything. Indicate so by setting the expected - // timestamp to 0 which means that the backfill goes to the - // beginning of time. - backfillState.FirstExpectedTimestamp = 0 - } - err = backfillState.Upsert(ctx) - if err != nil { - log.Err(err).Msg("Failed to mark backfill state as completed in database") - } - portal.updateBackfillStatus(ctx, backfillState) - } -} - -func (user *User) storeHistorySync(evt *waProto.HistorySync) { - if evt == nil || evt.SyncType == nil { - return - } - log := user.zlog.With(). - Str("method", "User.storeHistorySync"). - Str("sync_type", evt.GetSyncType().String()). - Uint32("chunk_order", evt.GetChunkOrder()). - Uint32("progress", evt.GetProgress()). - Logger() - ctx := log.WithContext(context.TODO()) - if evt.GetGlobalSettings() != nil { - log.Debug().Interface("global_settings", evt.GetGlobalSettings()).Msg("Got global settings in history sync") - } - if evt.GetSyncType() == waProto.HistorySync_INITIAL_STATUS_V3 || evt.GetSyncType() == waProto.HistorySync_PUSH_NAME || evt.GetSyncType() == waProto.HistorySync_NON_BLOCKING_DATA { - log.Debug(). - Int("conversation_count", len(evt.GetConversations())). - Int("pushname_count", len(evt.GetPushnames())). - Int("status_count", len(evt.GetStatusV3Messages())). - Int("recent_sticker_count", len(evt.GetRecentStickers())). - Int("past_participant_count", len(evt.GetPastParticipants())). - Msg("Ignoring history sync") - return - } - log.Info(). - Int("conversation_count", len(evt.GetConversations())). - Int("past_participant_count", len(evt.GetPastParticipants())). - Msg("Storing history sync") - - successfullySavedTotal := 0 - failedToSaveTotal := 0 - totalMessageCount := 0 - for _, conv := range evt.GetConversations() { - jid, err := types.ParseJID(conv.GetId()) - if err != nil { - totalMessageCount += len(conv.GetMessages()) - log.Warn().Err(err). - Str("chat_jid", conv.GetId()). - Int("msg_count", len(conv.GetMessages())). - Msg("Failed to parse chat JID in history sync") - continue - } else if jid.Server == types.BroadcastServer { - log.Debug().Str("chat_jid", jid.String()).Msg("Skipping broadcast list in history sync") - continue - } else if jid.Server == types.HiddenUserServer { - log.Debug().Str("chat_jid", jid.String()).Msg("Skipping hidden user JID chat in history sync") - continue - } - totalMessageCount += len(conv.GetMessages()) - log := log.With(). - Str("chat_jid", jid.String()). - Int("msg_count", len(conv.GetMessages())). - Logger() - - var portal *Portal - initPortal := func() { - if portal != nil { - return - } - portal = user.GetPortalByJID(jid) - historySyncConversation := user.bridge.DB.HistorySync.NewConversationWithValues( - user.MXID, - conv.GetId(), - portal.Key, - getConversationTimestamp(conv), - conv.GetMuteEndTime(), - conv.GetArchived(), - conv.GetPinned(), - conv.GetDisappearingMode().GetInitiator(), - conv.GetEndOfHistoryTransferType(), - conv.EphemeralExpiration, - conv.GetMarkedAsUnread(), - conv.GetUnreadCount()) - err := historySyncConversation.Upsert(ctx) - if err != nil { - log.Err(err).Msg("Failed to insert history sync conversation into database") - } - } - - var minTime, maxTime time.Time - var minTimeIndex, maxTimeIndex int - - successfullySaved := 0 - failedToSave := 0 - unsupportedTypes := 0 - for i, rawMsg := range conv.GetMessages() { - // Don't store messages that will just be skipped. - msgEvt, err := user.Client.ParseWebMessage(jid, rawMsg.GetMessage()) - if err != nil { - log.Warn().Err(err). - Int("msg_index", i). - Str("msg_id", rawMsg.GetMessage().GetKey().GetId()). - Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()). - Msg("Dropping historical message due to parse error") - continue - } - if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) { - minTime = msgEvt.Info.Timestamp - minTimeIndex = i - } - if maxTime.IsZero() || msgEvt.Info.Timestamp.After(maxTime) { - maxTime = msgEvt.Info.Timestamp - maxTimeIndex = i - } - - msgType := getMessageType(msgEvt.Message) - if msgType == "unknown" || msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") || !containsSupportedMessage(msgEvt.Message) { - unsupportedTypes++ - continue - } - - initPortal() - - message, err := user.bridge.DB.HistorySync.NewMessageWithValues(user.MXID, conv.GetId(), msgEvt.Info.ID, rawMsg) - if err != nil { - log.Error().Err(err). - Int("msg_index", i). - Str("msg_id", msgEvt.Info.ID). - Time("msg_time", msgEvt.Info.Timestamp). - Msg("Failed to save historical message") - failedToSave++ - continue - } - err = message.Insert(ctx) - if err != nil { - log.Error().Err(err). - Int("msg_index", i). - Str("msg_id", msgEvt.Info.ID). - Time("msg_time", msgEvt.Info.Timestamp). - Msg("Failed to save historical message") - failedToSave++ - } else { - successfullySaved++ - } - } - successfullySavedTotal += successfullySaved - failedToSaveTotal += failedToSave - log.Debug(). - Int("saved_count", successfullySaved). - Int("failed_count", failedToSave). - Int("unsupported_msg_type_count", unsupportedTypes). - Time("lowest_time", minTime). - Int("lowest_time_index", minTimeIndex). - Time("highest_time", maxTime). - Int("highest_time_index", maxTimeIndex). - Dict("metadata", zerolog.Dict(). - Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()). - Bool("marked_unread", conv.GetMarkedAsUnread()). - Bool("archived", conv.GetArchived()). - Uint32("pinned", conv.GetPinned()). - Uint64("mute_end", conv.GetMuteEndTime()). - Uint32("unread_count", conv.GetUnreadCount()), - ). - Msg("Saved messages from history sync conversation") - } - log.Info(). - Int("total_saved_count", successfullySavedTotal). - Int("total_failed_count", failedToSaveTotal). - Int("total_message_count", totalMessageCount). - Msg("Finished storing history sync") - - // If this was the initial bootstrap, enqueue immediate backfills for the - // most recent portals. If it's the last history sync event, start - // backfilling the rest of the history of the portals. - if user.bridge.Config.Bridge.HistorySync.Backfill { - user.enqueueBackfillsTimer.Reset(EnqueueBackfillsDelay) - } -} - -func getConversationTimestamp(conv *waProto.Conversation) uint64 { - convTs := conv.GetConversationTimestamp() - if convTs == 0 && len(conv.GetMessages()) > 0 { - convTs = conv.Messages[0].GetMessage().GetMessageTimestamp() - } - return convTs -} - -func (user *User) EnqueueImmediateBackfills(ctx context.Context, portals []*Portal) { - for priority, portal := range portals { - maxMessages := user.bridge.Config.Bridge.HistorySync.Immediate.MaxEvents - initialBackfill := user.bridge.DB.BackfillQueue.NewWithValues(user.MXID, database.BackfillImmediate, priority, portal.Key, nil, maxMessages, maxMessages, 0) - err := initialBackfill.Insert(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_key", portal.Key). - Msg("Failed to insert immediate backfill into database") - } - } -} - -func (user *User) EnqueueDeferredBackfills(ctx context.Context, portals []*Portal) { - numPortals := len(portals) - for stageIdx, backfillStage := range user.bridge.Config.Bridge.HistorySync.Deferred { - for portalIdx, portal := range portals { - var startDate *time.Time = nil - if backfillStage.StartDaysAgo > 0 { - startDaysAgo := time.Now().AddDate(0, 0, -backfillStage.StartDaysAgo) - startDate = &startDaysAgo - } - backfillMessages := user.bridge.DB.BackfillQueue.NewWithValues( - user.MXID, database.BackfillDeferred, stageIdx*numPortals+portalIdx, portal.Key, startDate, backfillStage.MaxBatchEvents, -1, backfillStage.BatchDelay) - err := backfillMessages.Insert(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_key", portal.Key). - Msg("Failed to insert deferred backfill into database") - } - } - } -} - -func (user *User) EnqueueForwardBackfills(ctx context.Context, portals []*Portal) { - for priority, portal := range portals { - lastMsg, err := user.bridge.DB.Message.GetLastInChat(ctx, portal.Key) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_key", portal.Key). - Msg("Failed to get last message in chat to enqueue forward backfill") - } else if lastMsg == nil { - continue - } - backfill := user.bridge.DB.BackfillQueue.NewWithValues( - user.MXID, database.BackfillForward, priority, portal.Key, &lastMsg.Timestamp, -1, -1, 0) - err = backfill.Insert(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("portal_key", portal.Key). - Msg("Failed to insert forward backfill into database") - } - } -} - -// endregion -// region Portal backfilling - -func (portal *Portal) deterministicEventID(sender types.JID, messageID types.MessageID, partName string) id.EventID { - data := fmt.Sprintf("%s/whatsapp/%s/%s", portal.MXID, sender.User, messageID) - if partName != "" { - data += "/" + partName - } - sum := sha256.Sum256([]byte(data)) - return id.EventID(fmt.Sprintf("$%s:whatsapp.com", base64.RawURLEncoding.EncodeToString(sum[:]))) -} - -var ( - BackfillStatusEvent = event.Type{Type: "com.beeper.backfill_status", Class: event.StateEventType} -) - -func (portal *Portal) backfill(ctx context.Context, source *User, messages []*waProto.WebMessageInfo, isForward, atomicMarkAsRead bool) { - log := zerolog.Ctx(ctx) - var req mautrix.ReqBeeperBatchSend - var infos []*wrappedInfo - - req.Forward = isForward - if atomicMarkAsRead { - req.MarkReadBy = source.MXID - } - - log.Info(). - Bool("forward", isForward). - Int("message_count", len(messages)). - Msg("Processing history sync message batch") - // The messages are ordered newest to oldest, so iterate them in reverse order. - for i := len(messages) - 1; i >= 0; i-- { - webMsg := messages[i] - msgEvt, err := source.Client.ParseWebMessage(portal.Key.JID, webMsg) - if err != nil { - continue - } - log := log.With(). - Str("message_id", msgEvt.Info.ID). - Stringer("message_sender", msgEvt.Info.Sender). - Logger() - ctx := log.WithContext(ctx) - - msgType := getMessageType(msgEvt.Message) - if msgType == "unknown" || msgType == "ignore" || msgType == "unknown_protocol" { - if msgType != "ignore" { - log.Debug().Msg("Skipping message with unknown type in backfill") - } - continue - } - if webMsg.GetPushName() != "" && webMsg.GetPushName() != "-" { - existingContact, _ := source.Client.Store.Contacts.GetContact(msgEvt.Info.Sender) - if !existingContact.Found || existingContact.PushName == "" { - changed, _, err := source.Client.Store.Contacts.PutPushName(msgEvt.Info.Sender, webMsg.GetPushName()) - if err != nil { - log.Err(err).Msg("Failed to save push name from historical message to device store") - } else if changed { - log.Debug().Str("push_name", webMsg.GetPushName()).Msg("Got push name from historical message") - } - } - } - puppet := portal.getMessagePuppet(ctx, source, &msgEvt.Info) - if puppet == nil { - continue - } - - converted := portal.convertMessage(ctx, puppet.IntentFor(portal), source, &msgEvt.Info, msgEvt.Message, true) - if converted == nil { - log.Debug().Msg("Skipping unsupported message in backfill") - continue - } - if converted.ReplyTo != nil { - portal.SetReply(ctx, converted.Content, converted.ReplyTo, true) - } - err = portal.appendBatchEvents(ctx, source, converted, &msgEvt.Info, webMsg, &req.Events, &infos) - if err != nil { - log.Err(err).Msg("Failed to handle message in backfill") - } - } - log.Info().Int("event_count", len(req.Events)).Msg("Made Matrix events from messages in batch") - - if len(req.Events) == 0 { - return - } - - resp, err := portal.MainIntent().BeeperBatchSend(ctx, portal.MXID, &req) - if err != nil { - log.Err(err).Msg("Failed to send batch of messages") - return - } - err = portal.bridge.DB.DoTxn(ctx, nil, func(ctx context.Context) error { - return portal.finishBatch(ctx, resp.EventIDs, infos) - }) - if err != nil { - log.Err(err).Msg("Failed to save message batch to database") - return - } - log.Info().Msg("Successfully sent backfill batch") - if portal.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia { - go portal.requestMediaRetries(context.TODO(), source, resp.EventIDs, infos) - } -} - -func (portal *Portal) requestMediaRetries(ctx context.Context, source *User, eventIDs []id.EventID, infos []*wrappedInfo) { - for i, info := range infos { - if info != nil && info.Error == database.MsgErrMediaNotFound && info.MediaKey != nil { - switch portal.bridge.Config.Bridge.HistorySync.MediaRequests.RequestMethod { - case config.MediaRequestMethodImmediate: - err := source.Client.SendMediaRetryReceipt(info.MessageInfo, info.MediaKey) - if err != nil { - portal.zlog.Err(err).Str("message_id", info.ID).Msg("Failed to send post-backfill media retry request") - } else { - portal.zlog.Debug().Str("message_id", info.ID).Msg("Sent post-backfill media retry request") - } - case config.MediaRequestMethodLocalTime: - req := portal.bridge.DB.MediaBackfillRequest.NewMediaBackfillRequestWithValues(source.MXID, portal.Key, eventIDs[i], info.MediaKey) - err := req.Upsert(ctx) - if err != nil { - portal.zlog.Err(err). - Stringer("event_id", eventIDs[i]). - Msg("Failed to upsert media backfill request") - } - } - } - } -} - -func (portal *Portal) appendBatchEvents(ctx context.Context, source *User, converted *ConvertedMessage, info *types.MessageInfo, raw *waProto.WebMessageInfo, eventsArray *[]*event.Event, infoArray *[]*wrappedInfo) error { - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - mainEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, converted.Content, converted.Extra, "") - if err != nil { - return err - } - expirationStart := info.Timestamp - if raw.GetEphemeralStartTimestamp() > 0 { - expirationStart = time.Unix(int64(raw.GetEphemeralStartTimestamp()), 0) - } - mainInfo := &wrappedInfo{ - MessageInfo: info, - Type: database.MsgNormal, - SenderMXID: mainEvt.Sender, - Error: converted.Error, - MediaKey: converted.MediaKey, - ExpirationStart: expirationStart, - ExpiresIn: converted.ExpiresIn, - } - if converted.Caption != nil { - captionEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, converted.Caption, nil, "caption") - if err != nil { - return err - } - *eventsArray = append(*eventsArray, mainEvt, captionEvt) - *infoArray = append(*infoArray, mainInfo, nil) - } else { - *eventsArray = append(*eventsArray, mainEvt) - *infoArray = append(*infoArray, mainInfo) - } - if converted.MultiEvent != nil { - for i, subEvtContent := range converted.MultiEvent { - subEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, subEvtContent, nil, fmt.Sprintf("multi-%d", i)) - if err != nil { - return err - } - *eventsArray = append(*eventsArray, subEvt) - *infoArray = append(*infoArray, nil) - } - } - for _, reaction := range raw.GetReactions() { - reactionEvent, reactionInfo := portal.wrapBatchReaction(ctx, source, reaction, mainEvt.ID, info.Timestamp) - if reactionEvent != nil { - *eventsArray = append(*eventsArray, reactionEvent) - *infoArray = append(*infoArray, &wrappedInfo{ - MessageInfo: reactionInfo, - SenderMXID: reactionEvent.Sender, - ReactionTarget: info.ID, - Type: database.MsgReaction, - }) - } - } - return nil -} - -func (portal *Portal) wrapBatchReaction(ctx context.Context, source *User, reaction *waProto.Reaction, mainEventID id.EventID, mainEventTS time.Time) (reactionEvent *event.Event, reactionInfo *types.MessageInfo) { - var senderJID types.JID - if reaction.GetKey().GetFromMe() { - senderJID = source.JID.ToNonAD() - } else if reaction.GetKey().GetParticipant() != "" { - senderJID, _ = types.ParseJID(reaction.GetKey().GetParticipant()) - } else if portal.IsPrivateChat() { - senderJID = portal.Key.JID - } - if senderJID.IsEmpty() { - return - } - reactionInfo = &types.MessageInfo{ - MessageSource: types.MessageSource{ - Chat: portal.Key.JID, - Sender: senderJID, - IsFromMe: reaction.GetKey().GetFromMe(), - IsGroup: portal.IsGroupChat(), - }, - ID: reaction.GetKey().GetId(), - Timestamp: mainEventTS, - } - puppet := portal.getMessagePuppet(ctx, source, reactionInfo) - if puppet == nil { - return - } - intent := puppet.IntentFor(portal) - content := event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: mainEventID, - Key: variationselector.Add(reaction.GetText()), - }, - } - if rawTS := reaction.GetSenderTimestampMS(); rawTS >= mainEventTS.UnixMilli() && rawTS <= time.Now().UnixMilli() { - reactionInfo.Timestamp = time.UnixMilli(rawTS) - } - wrappedContent := event.Content{Parsed: &content} - intent.AddDoublePuppetValue(&wrappedContent) - reactionEvent = &event.Event{ - ID: portal.deterministicEventID(senderJID, reactionInfo.ID, ""), - Type: event.EventReaction, - Content: wrappedContent, - Sender: intent.UserID, - Timestamp: reactionInfo.Timestamp.UnixMilli(), - } - return -} - -func (portal *Portal) wrapBatchEvent(ctx context.Context, info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, partName string) (*event.Event, error) { - wrappedContent := event.Content{ - Parsed: content, - Raw: extraContent, - } - newEventType, err := portal.encrypt(ctx, intent, &wrappedContent, eventType) - if err != nil { - return nil, err - } - intent.AddDoublePuppetValue(&wrappedContent) - return &event.Event{ - ID: portal.deterministicEventID(info.Sender, info.ID, partName), - Sender: intent.UserID, - Type: newEventType, - Timestamp: info.Timestamp.UnixMilli(), - Content: wrappedContent, - }, nil -} - -func (portal *Portal) finishBatch(ctx context.Context, eventIDs []id.EventID, infos []*wrappedInfo) error { - for i, info := range infos { - if info == nil { - continue - } - - eventID := eventIDs[i] - portal.markHandled(ctx, nil, info.MessageInfo, eventID, info.SenderMXID, true, false, info.Type, 0, info.Error) - if info.Type == database.MsgReaction { - portal.upsertReaction(ctx, nil, info.ReactionTarget, info.Sender, eventID, info.ID) - } - - if info.ExpiresIn > 0 { - portal.MarkDisappearing(ctx, eventID, info.ExpiresIn, info.ExpirationStart) - } - } - return nil -} - -func (portal *Portal) updateBackfillStatus(ctx context.Context, backfillState *database.BackfillState) { - backfillStatus := "backfilling" - if backfillState.BackfillComplete { - backfillStatus = "complete" - } - - _, err := portal.bridge.Bot.SendStateEvent(ctx, portal.MXID, BackfillStatusEvent, "", map[string]interface{}{ - "status": backfillStatus, - "first_timestamp": backfillState.FirstExpectedTimestamp * 1000, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send backfill status event to room") - } -} - -// endregion diff --git a/main.go b/main.go deleted file mode 100644 index 4b90bdf..0000000 --- a/main.go +++ /dev/null @@ -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 . - -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() -} diff --git a/matrix.go b/matrix.go deleted file mode 100644 index 38fafb8..0000000 --- a/matrix.go +++ /dev/null @@ -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 . - -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") - } - } -} diff --git a/messagetracking.go b/messagetracking.go deleted file mode 100644 index 9bac41e..0000000 --- a/messagetracking.go +++ /dev/null @@ -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 . - -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 -} diff --git a/metrics.go b/metrics.go deleted file mode 100644 index a74e9a7..0000000 --- a/metrics.go +++ /dev/null @@ -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 . - -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") - } -} diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 3a505f0..93524a2 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -87,10 +87,10 @@ func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistor Msg("Failed to parse chat JID in history sync") continue } 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 } 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 } totalMessageCount += len(conv.GetMessages()) diff --git a/portal.go b/portal.go deleted file mode 100644 index e977c03..0000000 --- a/portal.go +++ /dev/null @@ -1,5512 +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 . - -package main - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "image" - "image/color" - _ "image/gif" - "image/jpeg" - "image/png" - "io" - "maps" - "math" - "mime" - "net/http" - "reflect" - "runtime/debug" - "strconv" - "strings" - "sync" - "time" - - "github.com/rs/zerolog" - "github.com/tidwall/gjson" - "go.mau.fi/util/exzerolog" - cwebp "go.mau.fi/webp" - "go.mau.fi/whatsmeow" - waProto "go.mau.fi/whatsmeow/binary/proto" - "go.mau.fi/whatsmeow/proto/waMmsRetry" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "golang.org/x/exp/slices" - "golang.org/x/image/draw" - "golang.org/x/image/webp" - "google.golang.org/protobuf/proto" - - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exmime" - "go.mau.fi/util/ffmpeg" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/random" - "go.mau.fi/util/variationselector" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix-whatsapp/database" -) - -const StatusBroadcastTopic = "WhatsApp status updates from your contacts" -const StatusBroadcastName = "WhatsApp Status Broadcast" -const BroadcastTopic = "WhatsApp broadcast list" -const UnnamedBroadcastName = "Unnamed broadcast list" -const PrivateChatTopic = "WhatsApp private chat" - -var ErrStatusBroadcastDisabled = errors.New("status bridging is disabled") - -func (br *WABridge) GetPortalByMXID(mxid id.RoomID) *Portal { - ctx := context.TODO() - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal, ok := br.portalsByMXID[mxid] - if !ok { - dbPortal, err := br.DB.Portal.GetByMXID(ctx, mxid) - if err != nil { - br.ZLog.Err(err).Stringer("mxid", mxid).Msg("Failed to get portal by MXID") - return nil - } - return br.loadDBPortal(ctx, dbPortal, nil) - } - return portal -} - -func (br *WABridge) GetIPortal(mxid id.RoomID) bridge.Portal { - p := br.GetPortalByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - err := portal.Update(context.TODO()) - if err != nil { - portal.zlog.Err(err).Msg("Failed to mark portal as encrypted") - } -} - -func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() { - portal.events <- &PortalEvent{ - MatrixMessage: &PortalMatrixMessage{ - user: user.(*User), - evt: evt, - receivedAt: time.Now(), - }, - } - } -} - -func (br *WABridge) GetPortalByJID(key database.PortalKey) *Portal { - ctx := context.TODO() - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal, ok := br.portalsByJID[key] - if !ok { - dbPortal, err := br.DB.Portal.GetByJID(ctx, key) - if err != nil { - br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to get portal by JID") - return nil - } - return br.loadDBPortal(ctx, dbPortal, &key) - } - return portal -} - -func (br *WABridge) GetExistingPortalByJID(key database.PortalKey) *Portal { - ctx := context.TODO() - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal, ok := br.portalsByJID[key] - if !ok { - dbPortal, err := br.DB.Portal.GetByJID(ctx, key) - if err != nil { - br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to get portal by JID") - return nil - } - return br.loadDBPortal(ctx, dbPortal, nil) - } - return portal -} - -func (br *WABridge) GetAllPortals() []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAll(context.TODO())) -} - -func (br *WABridge) GetAllIPortals() (iportals []bridge.Portal) { - portals := br.GetAllPortals() - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *WABridge) GetAllPortalsByJID(jid types.JID) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllByJID(context.TODO(), jid)) -} - -func (br *WABridge) GetAllByParentGroup(jid types.JID) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllByParentGroup(context.TODO(), jid)) -} - -func (br *WABridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) []*Portal { - if err != nil { - br.ZLog.Err(err).Msg("Failed to get portals") - return nil - } - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - portal, ok := br.portalsByJID[dbPortal.Key] - if !ok { - portal = br.loadDBPortal(context.TODO(), dbPortal, nil) - } - output[index] = portal - } - return output -} - -func (br *WABridge) loadDBPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey) *Portal { - if dbPortal == nil { - if key == nil { - return nil - } - dbPortal = br.DB.Portal.New() - dbPortal.Key = *key - err := dbPortal.Insert(ctx) - if err != nil { - br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to insert new portal") - return nil - } - } - portal := br.NewPortal(dbPortal) - br.portalsByJID[portal.Key] = portal - if len(portal.MXID) > 0 { - br.portalsByMXID[portal.MXID] = portal - } - return portal -} - -func (portal *Portal) GetUsers() []*User { - // TODO what's this for? - return nil -} - -func (br *WABridge) NewManualPortal(key database.PortalKey) *Portal { - dbPortal := br.DB.Portal.New() - dbPortal.Key = key - return br.NewPortal(dbPortal) -} - -func (br *WABridge) NewPortal(dbPortal *database.Portal) *Portal { - portal := &Portal{ - Portal: dbPortal, - bridge: br, - events: make(chan *PortalEvent, br.Config.Bridge.PortalMessageBuffer), - mediaErrorCache: make(map[types.MessageID]*FailedMediaMeta), - } - portal.updateLogger() - go portal.handleMessageLoop() - return portal -} - -func (portal *Portal) updateLogger() { - logWith := portal.bridge.ZLog.With().Stringer("portal_key", portal.Key) - if portal.MXID != "" { - logWith = logWith.Stringer("room_id", portal.MXID) - } - portal.zlog = logWith.Logger() -} - -const recentlyHandledLength = 100 - -type fakeMessage struct { - Sender types.JID - Text string - ID string - Time time.Time - Important bool -} - -type PortalEvent struct { - Message *PortalMessage - MatrixMessage *PortalMatrixMessage -} - -type PortalMessage struct { - evt *events.Message - undecryptable *events.UndecryptableMessage - receipt *events.Receipt - fake *fakeMessage - source *User -} - -type PortalMatrixMessage struct { - evt *event.Event - user *User - receivedAt time.Time -} - -type recentlyHandledWrapper struct { - id types.MessageID - err database.MessageErrorType -} - -type Portal struct { - *database.Portal - - bridge *WABridge - zlog zerolog.Logger - - roomCreateLock sync.Mutex - encryptLock sync.Mutex - backfillLock sync.Mutex - avatarLock sync.Mutex - - latestEventBackfillLock sync.Mutex - parentGroupUpdateLock sync.Mutex - - recentlyHandled [recentlyHandledLength]recentlyHandledWrapper - recentlyHandledLock sync.Mutex - recentlyHandledIndex uint8 - - currentlyTyping []id.UserID - currentlyTypingLock sync.Mutex - - events chan *PortalEvent - - mediaErrorCache map[types.MessageID]*FailedMediaMeta - - galleryCache []*event.MessageEventContent - galleryCacheRootEvent id.EventID - galleryCacheStart time.Time - galleryCacheReplyTo *ReplyInfo - galleryCacheSender types.JID - - currentlySleepingToDelete sync.Map - - relayUser *User - parentPortal *Portal -} - -const GalleryMaxTime = 10 * time.Minute - -func (portal *Portal) stopGallery() { - if portal.galleryCache != nil { - portal.galleryCache = nil - portal.galleryCacheSender = types.EmptyJID - portal.galleryCacheReplyTo = nil - portal.galleryCacheStart = time.Time{} - portal.galleryCacheRootEvent = "" - } -} - -func (portal *Portal) startGallery(evt *events.Message, msg *ConvertedMessage) { - portal.galleryCache = []*event.MessageEventContent{msg.Content} - portal.galleryCacheSender = evt.Info.Sender.ToNonAD() - portal.galleryCacheReplyTo = msg.ReplyTo - portal.galleryCacheStart = time.Now() -} - -func (portal *Portal) extendGallery(msg *ConvertedMessage) int { - portal.galleryCache = append(portal.galleryCache, msg.Content) - msg.Content = &event.MessageEventContent{ - MsgType: event.MsgBeeperGallery, - Body: "Sent a gallery", - BeeperGalleryImages: portal.galleryCache, - } - msg.Content.SetEdit(portal.galleryCacheRootEvent) - // Don't set the gallery images in the edit fallback - msg.Content.BeeperGalleryImages = nil - return len(portal.galleryCache) - 1 -} - -var ( - _ bridge.Portal = (*Portal)(nil) - _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) - _ bridge.MembershipHandlingPortal = (*Portal)(nil) - _ bridge.MetaHandlingPortal = (*Portal)(nil) - _ bridge.TypingPortal = (*Portal)(nil) -) - -func (portal *Portal) handleWhatsAppMessageLoopItem(msg *PortalMessage) { - log := portal.zlog.With(). - Str("action", "handle whatsapp event"). - Stringer("source_user_jid", msg.source.JID). - Stringer("source_user_mxid", msg.source.MXID). - Logger() - ctx := log.WithContext(context.TODO()) - if len(portal.MXID) == 0 { - if msg.fake == nil && msg.undecryptable == nil && (msg.evt == nil || !containsSupportedMessage(msg.evt.Message)) { - log.Debug().Msg("Not creating portal room for incoming message: message is not a chat message") - return - } - log.Debug().Msg("Creating Matrix room from incoming message") - err := portal.CreateMatrixRoom(ctx, msg.source, nil, nil, false, true) - if err != nil { - log.Err(err).Msg("Failed to create portal room") - return - } - } - portal.latestEventBackfillLock.Lock() - defer portal.latestEventBackfillLock.Unlock() - switch { - case msg.evt != nil: - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c. - Str("message_id", msg.evt.Info.ID). - Stringer("message_sender", msg.evt.Info.Sender) - }) - portal.handleMessage(ctx, msg.source, msg.evt, false) - case msg.receipt != nil: - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("receipt_type", msg.receipt.Type.GoString()) - }) - portal.handleReceipt(ctx, msg.receipt, msg.source) - case msg.undecryptable != nil: - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c. - Str("message_id", msg.undecryptable.Info.ID). - Stringer("message_sender", msg.undecryptable.Info.Sender). - Bool("undecryptable", true) - }) - portal.stopGallery() - portal.handleUndecryptableMessage(ctx, msg.source, msg.undecryptable) - case msg.fake != nil: - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c. - Str("fake_message_id", msg.fake.ID). - Stringer("message_sender", msg.fake.Sender) - }) - portal.stopGallery() - msg.fake.ID = "FAKE::" + msg.fake.ID - portal.handleFakeMessage(ctx, *msg.fake) - default: - log.Warn().Any("event_data", msg).Msg("Unexpected PortalMessage with no message") - } -} - -func (portal *Portal) handleMatrixMessageLoopItem(msg *PortalMatrixMessage) { - log := portal.zlog.With(). - Str("action", "handle matrix event"). - Stringer("event_id", msg.evt.ID). - Str("event_type", msg.evt.Type.Type). - Stringer("sender", msg.evt.Sender). - Logger() - ctx := log.WithContext(context.TODO()) - portal.latestEventBackfillLock.Lock() - defer portal.latestEventBackfillLock.Unlock() - evtTS := time.UnixMilli(msg.evt.Timestamp) - timings := messageTimings{ - initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS), - decrypt: msg.evt.Mautrix.DecryptionDuration, - portalQueue: time.Since(msg.receivedAt), - totalReceive: time.Since(evtTS), - } - implicitRRStart := time.Now() - portal.handleMatrixReadReceipt(ctx, msg.user, "", evtTS, false) - timings.implicitRR = time.Since(implicitRRStart) - switch msg.evt.Type { - case event.EventMessage, event.EventSticker, TypeMSC3381V2PollResponse, TypeMSC3381PollResponse, TypeMSC3381PollStart: - portal.HandleMatrixMessage(ctx, msg.user, msg.evt, timings) - case event.EventRedaction: - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("redaction_target_mxid", msg.evt.Redacts) - }) - portal.HandleMatrixRedaction(ctx, msg.user, msg.evt) - case event.EventReaction: - portal.HandleMatrixReaction(ctx, msg.user, msg.evt) - default: - log.Warn().Msg("Unsupported event type in portal message channel") - } -} - -func (portal *Portal) handleDeliveryReceipt(ctx context.Context, receipt *events.Receipt, source *User) { - if !portal.IsPrivateChat() { - return - } - log := zerolog.Ctx(ctx) - for _, msgID := range receipt.MessageIDs { - msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID) - if err != nil { - log.Err(err).Str("message_id", msgID).Msg("Failed to get receipt target message") - continue - } else if msg == nil || msg.IsFakeMXID() { - continue - } - if msg.Sender == source.JID { - portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{ - EventID: msg.MXID, - RoomID: portal.MXID, - Step: status.MsgStepRemote, - Timestamp: jsontime.UM(receipt.Timestamp), - Status: status.MsgStatusDelivered, - ReportedBy: status.MsgReportedByBridge, - }) - portal.sendStatusEvent(ctx, msg.MXID, "", nil, &[]id.UserID{portal.MainIntent().UserID}) - } - } -} - -func (portal *Portal) handleReceipt(ctx context.Context, receipt *events.Receipt, source *User) { - if receipt.Sender.Server != types.DefaultUserServer { - // TODO handle lids - return - } - if receipt.Type == types.ReceiptTypeDelivered { - portal.handleDeliveryReceipt(ctx, receipt, source) - return - } - // The order of the message ID array depends on the sender's platform, so we just have to find - // the last message based on timestamp. Also, timestamps only have second precision, so if - // there are many messages at the same second just mark them all as read, because we don't - // know which one is last - markAsRead := make([]*database.Message, 0, 1) - var bestTimestamp time.Time - log := zerolog.Ctx(ctx) - for _, msgID := range receipt.MessageIDs { - msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID) - if err != nil { - log.Err(err).Str("message_id", msgID).Msg("Failed to get receipt target message") - } else if msg == nil || msg.IsFakeMXID() { - continue - } - if msg.Timestamp.After(bestTimestamp) { - bestTimestamp = msg.Timestamp - markAsRead = append(markAsRead[:0], msg) - } else if msg != nil && msg.Timestamp.Equal(bestTimestamp) { - markAsRead = append(markAsRead, msg) - } - } - if receipt.Sender.User == source.JID.User { - if len(markAsRead) > 0 { - source.SetLastReadTS(ctx, portal.Key, markAsRead[0].Timestamp) - } else { - source.SetLastReadTS(ctx, portal.Key, receipt.Timestamp) - } - } - intent := portal.bridge.GetPuppetByJID(receipt.Sender).IntentFor(portal) - for _, msg := range markAsRead { - err := intent.SetReadMarkers(ctx, portal.MXID, source.makeReadMarkerContent(msg.MXID, intent.IsCustomPuppet)) - if err != nil { - log.Err(err). - Stringer("message_mxid", msg.MXID). - Stringer("read_by_user_mxid", intent.UserID). - Msg("Failed to mark message as read") - } else { - log.Debug(). - Stringer("message_mxid", msg.MXID). - Stringer("read_by_user_mxid", intent.UserID). - Msg("Marked message as read") - } - } -} - -func (portal *Portal) handleMessageLoop() { - for { - portal.handleOneMessageLoopItem() - } -} - -func (portal *Portal) handleOneMessageLoopItem() { - defer func() { - if err := recover(); err != nil { - logEvt := portal.zlog.WithLevel(zerolog.FatalLevel). - Str(zerolog.ErrorStackFieldName, string(debug.Stack())) - actualErr, ok := err.(error) - if ok { - logEvt = logEvt.Err(actualErr) - } else { - logEvt = logEvt.Any(zerolog.ErrorFieldName, err) - } - logEvt.Msg("Portal message handler panicked") - } - }() - select { - case msg := <-portal.events: - if msg.Message != nil { - portal.handleWhatsAppMessageLoopItem(msg.Message) - } else if msg.MatrixMessage != nil { - portal.handleMatrixMessageLoopItem(msg.MatrixMessage) - } else { - portal.zlog.Warn().Msg("Unexpected PortalEvent with no data") - } - } -} - -func containsSupportedMessage(waMsg *waProto.Message) bool { - if waMsg == nil { - return false - } - return waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil || waMsg.ImageMessage != nil || - waMsg.StickerMessage != nil || waMsg.AudioMessage != nil || waMsg.VideoMessage != nil || waMsg.PtvMessage != nil || - waMsg.DocumentMessage != nil || waMsg.ContactMessage != nil || waMsg.LocationMessage != nil || - waMsg.LiveLocationMessage != nil || waMsg.GroupInviteMessage != nil || waMsg.ContactsArrayMessage != nil || - waMsg.HighlyStructuredMessage != nil || waMsg.TemplateMessage != nil || waMsg.TemplateButtonReplyMessage != nil || - waMsg.ListMessage != nil || waMsg.ListResponseMessage != nil || waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil -} - -func getMessageType(waMsg *waProto.Message) string { - switch { - case waMsg == nil: - return "ignore" - case waMsg.Conversation != nil, waMsg.ExtendedTextMessage != nil: - return "text" - case waMsg.ImageMessage != nil: - return fmt.Sprintf("image %s", waMsg.GetImageMessage().GetMimetype()) - case waMsg.StickerMessage != nil: - return fmt.Sprintf("sticker %s", waMsg.GetStickerMessage().GetMimetype()) - case waMsg.VideoMessage != nil: - return fmt.Sprintf("video %s", waMsg.GetVideoMessage().GetMimetype()) - case waMsg.PtvMessage != nil: - return fmt.Sprintf("round video %s", waMsg.GetPtvMessage().GetMimetype()) - case waMsg.AudioMessage != nil: - return fmt.Sprintf("audio %s", waMsg.GetAudioMessage().GetMimetype()) - case waMsg.DocumentMessage != nil: - return fmt.Sprintf("document %s", waMsg.GetDocumentMessage().GetMimetype()) - case waMsg.ContactMessage != nil: - return "contact" - case waMsg.ContactsArrayMessage != nil: - return "contact array" - case waMsg.LocationMessage != nil: - return "location" - case waMsg.LiveLocationMessage != nil: - return "live location start" - case waMsg.GroupInviteMessage != nil: - return "group invite" - case waMsg.ReactionMessage != nil: - return "reaction" - case waMsg.EncReactionMessage != nil: - return "encrypted reaction" - case waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil || waMsg.PollCreationMessageV3 != nil: - return "poll create" - case waMsg.PollUpdateMessage != nil: - return "poll update" - case waMsg.ProtocolMessage != nil: - switch waMsg.GetProtocolMessage().GetType() { - case waProto.ProtocolMessage_REVOKE: - if waMsg.GetProtocolMessage().GetKey() == nil { - return "ignore" - } - return "revoke" - case waProto.ProtocolMessage_MESSAGE_EDIT: - return "edit" - case waProto.ProtocolMessage_EPHEMERAL_SETTING: - return "disappearing timer change" - case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC: - return "ignore" - default: - return fmt.Sprintf("unknown_protocol_%d", waMsg.GetProtocolMessage().GetType()) - } - case waMsg.ButtonsMessage != nil: - return "buttons" - case waMsg.ButtonsResponseMessage != nil: - return "buttons response" - case waMsg.TemplateMessage != nil: - return "template" - case waMsg.HighlyStructuredMessage != nil: - return "highly structured template" - case waMsg.TemplateButtonReplyMessage != nil: - return "template button reply" - case waMsg.InteractiveMessage != nil: - return "interactive" - case waMsg.ListMessage != nil: - return "list" - case waMsg.ProductMessage != nil: - return "product" - case waMsg.ListResponseMessage != nil: - return "list response" - case waMsg.OrderMessage != nil: - return "order" - case waMsg.InvoiceMessage != nil: - return "invoice" - case waMsg.SendPaymentMessage != nil, waMsg.RequestPaymentMessage != nil, - waMsg.DeclinePaymentRequestMessage != nil, waMsg.CancelPaymentRequestMessage != nil, - waMsg.PaymentInviteMessage != nil: - return "payment" - case waMsg.Call != nil: - return "call" - case waMsg.Chat != nil: - return "chat" - case waMsg.SenderKeyDistributionMessage != nil, waMsg.StickerSyncRmrMessage != nil: - return "ignore" - default: - return "unknown" - } -} - -func pluralUnit(val int, name string) string { - if val == 1 { - return fmt.Sprintf("%d %s", val, name) - } else if val == 0 { - return "" - } - return fmt.Sprintf("%d %ss", val, name) -} - -func naturalJoin(parts []string) string { - if len(parts) == 0 { - return "" - } else if len(parts) == 1 { - return parts[0] - } else if len(parts) == 2 { - return fmt.Sprintf("%s and %s", parts[0], parts[1]) - } else { - return fmt.Sprintf("%s and %s", strings.Join(parts[:len(parts)-1], ", "), parts[len(parts)-1]) - } -} - -func formatDuration(d time.Duration) string { - const Day = time.Hour * 24 - - var days, hours, minutes, seconds int - days, d = int(d/Day), d%Day - hours, d = int(d/time.Hour), d%time.Hour - minutes, d = int(d/time.Minute), d%time.Minute - seconds = int(d / time.Second) - - parts := make([]string, 0, 4) - if days > 0 { - parts = append(parts, pluralUnit(days, "day")) - } - if hours > 0 { - parts = append(parts, pluralUnit(hours, "hour")) - } - if minutes > 0 { - parts = append(parts, pluralUnit(seconds, "minute")) - } - if seconds > 0 { - parts = append(parts, pluralUnit(seconds, "second")) - } - return naturalJoin(parts) -} - -func (portal *Portal) convertMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, waMsg *waProto.Message, isBackfill bool) *ConvertedMessage { - switch { - case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil: - return portal.convertTextMessage(ctx, intent, source, waMsg) - case waMsg.TemplateMessage != nil: - return portal.convertTemplateMessage(ctx, intent, source, info, waMsg.GetTemplateMessage()) - case waMsg.HighlyStructuredMessage != nil: - return portal.convertTemplateMessage(ctx, intent, source, info, waMsg.GetHighlyStructuredMessage().GetHydratedHsm()) - case waMsg.TemplateButtonReplyMessage != nil: - return portal.convertTemplateButtonReplyMessage(ctx, intent, waMsg.GetTemplateButtonReplyMessage()) - case waMsg.ListMessage != nil: - return portal.convertListMessage(ctx, intent, source, waMsg.GetListMessage()) - case waMsg.ListResponseMessage != nil: - return portal.convertListResponseMessage(ctx, intent, waMsg.GetListResponseMessage()) - case waMsg.PollCreationMessage != nil: - return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessage()) - case waMsg.PollCreationMessageV2 != nil: - return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessageV2()) - case waMsg.PollCreationMessageV3 != nil: - return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessageV3()) - case waMsg.PollUpdateMessage != nil: - return portal.convertPollUpdateMessage(ctx, intent, source, info, waMsg.GetPollUpdateMessage()) - case waMsg.ImageMessage != nil: - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetImageMessage(), "photo", isBackfill) - case waMsg.StickerMessage != nil: - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetStickerMessage(), "sticker", isBackfill) - case waMsg.VideoMessage != nil: - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetVideoMessage(), "video attachment", isBackfill) - case waMsg.PtvMessage != nil: - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetPtvMessage(), "video message", isBackfill) - case waMsg.AudioMessage != nil: - typeName := "audio attachment" - if waMsg.GetAudioMessage().GetPTT() { - typeName = "voice message" - } - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetAudioMessage(), typeName, isBackfill) - case waMsg.DocumentMessage != nil: - return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetDocumentMessage(), "file attachment", isBackfill) - case waMsg.ContactMessage != nil: - return portal.convertContactMessage(ctx, intent, waMsg.GetContactMessage()) - case waMsg.ContactsArrayMessage != nil: - return portal.convertContactsArrayMessage(ctx, intent, waMsg.GetContactsArrayMessage()) - case waMsg.LocationMessage != nil: - return portal.convertLocationMessage(ctx, intent, waMsg.GetLocationMessage()) - case waMsg.LiveLocationMessage != nil: - return portal.convertLiveLocationMessage(ctx, intent, waMsg.GetLiveLocationMessage()) - case waMsg.GroupInviteMessage != nil: - return portal.convertGroupInviteMessage(ctx, intent, info, waMsg.GetGroupInviteMessage()) - case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waProto.ProtocolMessage_EPHEMERAL_SETTING: - portal.ExpirationTime = waMsg.ProtocolMessage.GetEphemeralExpiration() - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer") - } - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: portal.formatDisappearingMessageNotice(), - MsgType: event.MsgNotice, - }, - } - default: - return nil - } -} - -func (portal *Portal) implicitlyEnableDisappearingMessages(ctx context.Context, timer time.Duration) { - portal.ExpirationTime = uint32(timer.Seconds()) - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after implicitly enabling disappearing timer") - } - intent := portal.MainIntent() - if portal.Encrypted { - intent = portal.bridge.Bot - } - duration := formatDuration(time.Duration(portal.ExpirationTime) * time.Second) - _, err = portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", duration), - }, nil, 0) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to send notice about implicit disappearing timer") - } -} - -func (portal *Portal) UpdateGroupDisappearingMessages(ctx context.Context, sender *types.JID, timestamp time.Time, timer uint32) { - if portal.ExpirationTime == timer { - return - } - portal.ExpirationTime = timer - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer") - } - intent := portal.MainIntent() - if sender != nil && sender.Server == types.DefaultUserServer { - intent = portal.bridge.GetPuppetByJID(sender.ToNonAD()).IntentFor(portal) - } else { - sender = &types.EmptyJID - } - _, err = portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{ - Body: portal.formatDisappearingMessageNotice(), - MsgType: event.MsgNotice, - }, nil, timestamp.UnixMilli()) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Uint32("new_timer", timer). - Stringer("sender_jid", sender). - Msg("Failed to notify portal about disappearing message timer change") - } -} - -func (portal *Portal) formatDisappearingMessageNotice() string { - if portal.ExpirationTime == 0 { - return "Turned off disappearing messages" - } - return fmt.Sprintf("Set the disappearing message timer to %s", formatDuration(time.Duration(portal.ExpirationTime)*time.Second)) -} - -const UndecryptableMessageNotice = "Decrypting message from WhatsApp failed, waiting for sender to re-send... " + - "([learn more](https://faq.whatsapp.com/general/security-and-privacy/seeing-waiting-for-this-message-this-may-take-a-while))" - -var undecryptableMessageContent event.MessageEventContent - -func init() { - undecryptableMessageContent = format.RenderMarkdown(UndecryptableMessageNotice, true, false) - undecryptableMessageContent.MsgType = event.MsgNotice -} - -func (portal *Portal) handleUndecryptableMessage(ctx context.Context, source *User, evt *events.UndecryptableMessage) { - log := zerolog.Ctx(ctx) - if len(portal.MXID) == 0 { - log.Warn().Msg("handleUndecryptableMessage called even though portal.MXID is empty") - return - } else if portal.isRecentlyHandled(evt.Info.ID, database.MsgErrDecryptionFailed) { - log.Debug().Msg("Not handling recently handled message") - return - } else if existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, evt.Info.ID); err != nil { - log.Err(err).Msg("Failed to get message from database to check if undecryptable message is duplicate") - return - } else if existingMsg != nil { - log.Debug().Msg("Not handling duplicate message") - return - } - metricType := "error" - if evt.IsUnavailable { - metricType = "unavailable" - } - Analytics.Track(source.MXID, "WhatsApp undecryptable message", map[string]interface{}{ - "messageID": evt.Info.ID, - "undecryptableType": metricType, - }) - intent := portal.getMessageIntent(ctx, source, &evt.Info) - if intent == nil { - return - } - content := undecryptableMessageContent - resp, err := portal.sendMessage(ctx, intent, event.EventMessage, &content, nil, evt.Info.Timestamp.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send WhatsApp decryption error message to Matrix") - return - } - portal.finishHandling(ctx, nil, &evt.Info, resp.EventID, intent.UserID, database.MsgUnknown, 0, database.MsgErrDecryptionFailed) -} - -func (portal *Portal) handleFakeMessage(ctx context.Context, msg fakeMessage) { - log := zerolog.Ctx(ctx) - if portal.isRecentlyHandled(msg.ID, database.MsgNoError) { - log.Debug().Msg("Not handling recently handled message") - return - } else if existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msg.ID); err != nil { - log.Err(err).Msg("Failed to get message from database to check if fake message is duplicate") - return - } else if existingMsg != nil { - log.Debug().Msg("Not handling duplicate message") - return - } - if msg.Sender.Server != types.DefaultUserServer { - log.Debug().Msg("Not handling message from @lid user") - // TODO handle lids - return - } - intent := portal.bridge.GetPuppetByJID(msg.Sender).IntentFor(portal) - if !intent.IsCustomPuppet && portal.IsPrivateChat() && msg.Sender.User == portal.Key.Receiver.User && portal.Key.Receiver != portal.Key.JID { - log.Debug().Msg("Not handling fake message for user who doesn't have double puppeting enabled") - return - } - msgType := event.MsgNotice - if msg.Important { - msgType = event.MsgText - } - resp, err := portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{ - MsgType: msgType, - Body: msg.Text, - }, nil, msg.Time.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send fake message to Matrix") - } else { - portal.finishHandling(ctx, nil, &types.MessageInfo{ - ID: msg.ID, - Timestamp: msg.Time, - MessageSource: types.MessageSource{ - Sender: msg.Sender, - }, - }, resp.EventID, intent.UserID, database.MsgFake, 0, database.MsgNoError) - } -} - -func (portal *Portal) handleMessage(ctx context.Context, source *User, evt *events.Message, historical bool) { - log := zerolog.Ctx(ctx) - if len(portal.MXID) == 0 { - log.Warn().Msg("handleMessage called even though portal.MXID is empty") - return - } - msgID := evt.Info.ID - msgType := getMessageType(evt.Message) - if msgType == "ignore" { - return - } else if portal.isRecentlyHandled(msgID, database.MsgNoError) { - log.Debug().Msg("Not handling recently handled message") - return - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("wa_message_type", msgType) - }) - existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID) - if err != nil { - log.Err(err).Msg("Failed to get message from database to check if message is duplicate") - return - } - if existingMsg != nil { - if existingMsg.Error == database.MsgErrDecryptionFailed { - resolveType := "sender" - if evt.UnavailableRequestID != "" { - resolveType = "phone" - } - Analytics.Track(source.MXID, "WhatsApp undecryptable message resolved", map[string]interface{}{ - "messageID": evt.Info.ID, - "resolveType": resolveType, - }) - log.Debug().Str("resolved_via", resolveType).Msg("Got decryptable version of previously undecryptable message") - } else { - log.Debug().Msg("Not handling duplicate message") - return - } - } - var editTargetMsg *database.Message - if msgType == "edit" { - editTargetID := evt.Message.GetProtocolMessage().GetKey().GetId() - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("edit_target_id", editTargetID) - }) - editTargetMsg, err = portal.bridge.DB.Message.GetByJID(ctx, portal.Key, editTargetID) - if err != nil { - log.Err(err).Msg("Failed to get edit target message from database") - return - } else if editTargetMsg == nil { - log.Warn().Msg("Not handling edit: couldn't find edit target") - return - } else if editTargetMsg.Type != database.MsgNormal { - log.Warn().Str("edit_target_db_type", string(editTargetMsg.Type)). - Msg("Not handling edit: edit target is not a normal message") - return - } else if editTargetMsg.Sender.User != evt.Info.Sender.User { - log.Warn().Stringer("edit_target_sender", editTargetMsg.Sender). - Msg("Not handling edit: edit was sent by another user") - return - } - evt.Message = evt.Message.GetProtocolMessage().GetEditedMessage() - } - - intent := portal.getMessageIntent(ctx, source, &evt.Info) - if intent == nil { - return - } - converted := portal.convertMessage(ctx, intent, source, &evt.Info, evt.Message, false) - if converted != nil { - isGalleriable := portal.bridge.Config.Bridge.BeeperGalleries && - (evt.Message.ImageMessage != nil || evt.Message.VideoMessage != nil) && - (portal.galleryCache == nil || - (evt.Info.Sender.ToNonAD() == portal.galleryCacheSender && - converted.ReplyTo.Equals(portal.galleryCacheReplyTo) && - time.Since(portal.galleryCacheStart) < GalleryMaxTime)) && - // Captions aren't allowed in galleries (this needs to be checked before the caption is merged) - converted.Caption == nil && - // Images can't be edited - editTargetMsg == nil - - if !historical && portal.IsPrivateChat() && evt.Info.Sender.Device == 0 && converted.ExpiresIn > 0 && portal.ExpirationTime == 0 { - log.Info(). - Str("timer", converted.ExpiresIn.String()). - Msg("Implicitly enabling disappearing messages as incoming message is disappearing") - portal.implicitlyEnableDisappearingMessages(ctx, converted.ExpiresIn) - } - if evt.Info.IsIncomingBroadcast() { - if converted.Extra == nil { - converted.Extra = map[string]any{} - } - converted.Extra["fi.mau.whatsapp.source_broadcast_list"] = evt.Info.Chat.String() - } - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - var eventID id.EventID - var lastEventID id.EventID - if existingMsg != nil { - portal.MarkDisappearing(ctx, existingMsg.MXID, converted.ExpiresIn, evt.Info.Timestamp) - converted.Content.SetEdit(existingMsg.MXID) - } else if converted.ReplyTo != nil { - portal.SetReply(ctx, converted.Content, converted.ReplyTo, false) - } - dbMsgType := database.MsgNormal - if editTargetMsg != nil { - dbMsgType = database.MsgEdit - converted.Content.SetEdit(editTargetMsg.MXID) - } - galleryStarted := false - var galleryPart int - if isGalleriable { - if portal.galleryCache == nil { - portal.startGallery(evt, converted) - galleryStarted = true - } else { - galleryPart = portal.extendGallery(converted) - dbMsgType = database.MsgBeeperGallery - } - } else if editTargetMsg == nil { - // Stop collecting a gallery (except if it's an edit) - portal.stopGallery() - } - var resp *mautrix.RespSendEvent - resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, converted.Content, converted.Extra, evt.Info.Timestamp.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send WhatsApp message to Matrix") - } else { - if editTargetMsg == nil { - portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp) - } - eventID = resp.EventID - lastEventID = eventID - if galleryStarted { - portal.galleryCacheRootEvent = eventID - } else if galleryPart != 0 { - eventID = portal.galleryCacheRootEvent - } - } - // TODO figure out how to handle captions with undecryptable messages turning decryptable - if converted.Caption != nil && existingMsg == nil && editTargetMsg == nil { - resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, converted.Caption, nil, evt.Info.Timestamp.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to send caption of WhatsApp message to Matrix") - } else { - portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp) - lastEventID = resp.EventID - } - } - if converted.MultiEvent != nil && existingMsg == nil && editTargetMsg == nil { - for index, subEvt := range converted.MultiEvent { - resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, subEvt, nil, evt.Info.Timestamp.UnixMilli()) - if err != nil { - log.Err(err).Int("part_number", index+1).Msg("Failed to send sub-event of WhatsApp message to Matrix") - } else { - portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp) - lastEventID = resp.EventID - } - } - } - if source.MXID == intent.UserID && portal.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - // There are some edge cases (like call notices) where previous messages aren't marked as read - // when the user sends a message from another device, so just mark the new message as read to be safe. - // Hungryserv does this automatically, so the bridge doesn't need to do it manually. - err = intent.SetReadMarkers(ctx, portal.MXID, source.makeReadMarkerContent(lastEventID, true)) - if err != nil { - log.Warn().Err(err).Stringer("last_event_id", lastEventID). - Msg("Failed to mark last message as read after sending") - } - } - if len(eventID) != 0 { - portal.finishHandling(ctx, existingMsg, &evt.Info, eventID, intent.UserID, dbMsgType, galleryPart, converted.Error) - } - } else if msgType == "reaction" || msgType == "encrypted reaction" { - if evt.Message.GetEncReactionMessage() != nil { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("reaction_target_id", evt.Message.GetEncReactionMessage().GetTargetMessageKey().GetId()) - }) - decryptedReaction, err := source.Client.DecryptReaction(evt) - if err != nil { - log.Err(err).Msg("Failed to decrypt reaction") - } else { - portal.HandleMessageReaction(ctx, intent, source, &evt.Info, decryptedReaction, existingMsg) - } - } else { - portal.HandleMessageReaction(ctx, intent, source, &evt.Info, evt.Message.GetReactionMessage(), existingMsg) - } - } else if msgType == "revoke" { - portal.HandleMessageRevoke(ctx, source, &evt.Info, evt.Message.GetProtocolMessage().GetKey()) - if existingMsg != nil { - _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ - Reason: "The undecryptable message was actually the deletion of another message", - }) - err = existingMsg.UpdateMXID(ctx, "net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError) - if err != nil { - log.Err(err).Msg("Failed to update message in database after finding undecryptable message was a revoke message") - } - } - } else { - log.Warn().Any("event_info", evt.Info).Msg("Unhandled message") - if existingMsg != nil { - _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ - Reason: "The undecryptable message contained an unsupported message type", - }) - err = existingMsg.UpdateMXID(ctx, "net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError) - if err != nil { - log.Err(err).Msg("Failed to update message in database after finding undecryptable message was an unknown message") - } - } - return - } - portal.bridge.Metrics.TrackWhatsAppMessage(evt.Info.Timestamp, strings.Split(msgType, " ")[0]) -} - -func (portal *Portal) isRecentlyHandled(id types.MessageID, error database.MessageErrorType) bool { - start := portal.recentlyHandledIndex - lookingForMsg := recentlyHandledWrapper{id, error} - for i := start; i != start; i = (i - 1) % recentlyHandledLength { - if portal.recentlyHandled[i] == lookingForMsg { - return true - } - } - return false -} - -func (portal *Portal) markHandled(ctx context.Context, msg *database.Message, info *types.MessageInfo, mxid id.EventID, senderMXID id.UserID, isSent, recent bool, msgType database.MessageType, galleryPart int, errType database.MessageErrorType) *database.Message { - if msg == nil { - msg = portal.bridge.DB.Message.New() - msg.Chat = portal.Key - msg.JID = info.ID - msg.MXID = mxid - msg.GalleryPart = galleryPart - msg.Timestamp = info.Timestamp - msg.Sender = info.Sender - msg.SenderMXID = senderMXID - msg.Sent = isSent - msg.Type = msgType - msg.Error = errType - if info.IsIncomingBroadcast() { - msg.BroadcastListJID = info.Chat - } - err := msg.Insert(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to insert message to database") - } - } else { - err := msg.UpdateMXID(ctx, mxid, msgType, errType) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update message in database") - } - } - - if recent { - portal.recentlyHandledLock.Lock() - index := portal.recentlyHandledIndex - portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength - portal.recentlyHandledLock.Unlock() - portal.recentlyHandled[index] = recentlyHandledWrapper{msg.JID, errType} - } - return msg -} - -func (portal *Portal) getMessagePuppet(ctx context.Context, user *User, info *types.MessageInfo) (puppet *Puppet) { - if info.IsFromMe { - return portal.bridge.GetPuppetByJID(user.JID) - } else if portal.IsPrivateChat() { - puppet = portal.bridge.GetPuppetByJID(portal.Key.JID) - } else if !info.Sender.IsEmpty() { - puppet = portal.bridge.GetPuppetByJID(info.Sender) - } - if puppet == nil { - zerolog.Ctx(ctx).Warn().Msg("Message doesn't seem to have a valid sender: puppet is nil") - return nil - } - user.EnqueuePortalResync(portal) - puppet.SyncContact(ctx, user, true, true, "handling message") - return puppet -} - -func (portal *Portal) getMessageIntent(ctx context.Context, user *User, info *types.MessageInfo) *appservice.IntentAPI { - if portal.IsNewsletter() && info.Sender == info.Chat { - return portal.MainIntent() - } - puppet := portal.getMessagePuppet(ctx, user, info) - if puppet == nil { - return nil - } - intent := puppet.IntentFor(portal) - if !intent.IsCustomPuppet && portal.IsPrivateChat() && info.Sender.User == portal.Key.Receiver.User && portal.Key.Receiver != portal.Key.JID { - zerolog.Ctx(ctx).Debug().Msg("Not handling message: user doesn't have double puppeting enabled") - return nil - } - return intent -} - -func (portal *Portal) finishHandling(ctx context.Context, existing *database.Message, message *types.MessageInfo, mxid id.EventID, senderMXID id.UserID, msgType database.MessageType, galleryPart int, errType database.MessageErrorType) { - portal.markHandled(ctx, existing, message, mxid, senderMXID, true, true, msgType, galleryPart, errType) - portal.sendDeliveryReceipt(ctx, mxid) - logEvt := zerolog.Ctx(ctx).Debug(). - Stringer("matrix_event_id", mxid) - if errType != database.MsgNoError { - logEvt.Str("error_type", string(errType)) - } - logEvt.Msg("Successfully handled WhatsApp message") -} - -func (portal *Portal) kickExtraUsers(ctx context.Context, participantMap map[types.JID]bool) { - members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get member list to kick extra users") - return - } - for member := range members.Joined { - jid, ok := portal.bridge.ParsePuppetMXID(member) - if ok { - _, shouldBePresent := participantMap[jid] - if !shouldBePresent { - _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{ - UserID: member, - Reason: "User had left this WhatsApp chat", - }) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Stringer("user_id", member). - Msg("Failed to kick extra user from room") - } - } - } - } -} - -//func (portal *Portal) SyncBroadcastRecipients(source *User, metadata *whatsapp.BroadcastListInfo) { -// participantMap := make(map[whatsapp.JID]bool) -// for _, recipient := range metadata.Recipients { -// participantMap[recipient.JID] = true -// -// puppet := portal.bridge.GetPuppetByJID(recipient.JID) -// puppet.SyncContactIfNecessary(source) -// err := puppet.DefaultIntent().EnsureJoined(portal.MXID) -// if err != nil { -// portal.log.Warnfln("Failed to make puppet of %s join %s: %v", recipient.JID, portal.MXID, err) -// } -// } -// portal.kickExtraUsers(participantMap) -//} - -func (portal *Portal) syncParticipant(ctx context.Context, source *User, participant types.GroupParticipant, puppet *Puppet, user *User, wg *sync.WaitGroup) { - defer func() { - wg.Done() - if err := recover(); err != nil { - zerolog.Ctx(ctx).Error(). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()). - Any(zerolog.ErrorFieldName, err). - Stringer("participant_jid", participant.JID). - Msg("Syncing participant panicked") - } - }() - puppet.SyncContact(ctx, source, true, false, "group participant") - if portal.MXID != "" { - if user != nil && user != source { - portal.ensureUserInvited(ctx, user) - } - if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { - err := puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Stringer("participant_jid", participant.JID). - Msg("Failed to make ghost user join portal") - } - } - } -} - -func (portal *Portal) SyncParticipants(ctx context.Context, source *User, metadata *types.GroupInfo) ([]id.UserID, *event.PowerLevelsEventContent) { - if portal.IsNewsletter() { - return nil, nil - } - changed := false - var levels *event.PowerLevelsEventContent - var err error - if portal.MXID != "" { - levels, err = portal.MainIntent().PowerLevels(ctx, portal.MXID) - } - if levels == nil || err != nil { - levels = portal.GetBasePowerLevels() - changed = true - } - changed = portal.applyPowerLevelFixes(levels) || changed - var wg sync.WaitGroup - wg.Add(len(metadata.Participants)) - participantMap := make(map[types.JID]bool) - userIDs := make([]id.UserID, 0, len(metadata.Participants)) - log := zerolog.Ctx(ctx) - for _, participant := range metadata.Participants { - if participant.JID.IsEmpty() || participant.JID.Server != types.DefaultUserServer { - wg.Done() - // TODO handle lids - continue - } - log.Debug(). - Stringer("participant_jid", participant.JID). - Bool("is_admin", participant.IsAdmin). - Msg("Syncing participant") - participantMap[participant.JID] = true - puppet := portal.bridge.GetPuppetByJID(participant.JID) - user := portal.bridge.GetUserByJID(participant.JID) - if portal.bridge.Config.Bridge.ParallelMemberSync { - go portal.syncParticipant(ctx, source, participant, puppet, user, &wg) - } else { - portal.syncParticipant(ctx, source, participant, puppet, user, &wg) - } - - expectedLevel := 0 - if participant.IsSuperAdmin { - expectedLevel = 95 - } else if participant.IsAdmin { - expectedLevel = 50 - } - changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed - if user != nil { - userIDs = append(userIDs, user.MXID) - changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed - } - if user == nil || puppet.CustomMXID != user.MXID { - userIDs = append(userIDs, puppet.MXID) - } - } - if portal.MXID != "" { - if changed { - _, err = portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - log.Err(err).Msg("Failed to update power levels in room") - } - } - portal.kickExtraUsers(ctx, participantMap) - } - wg.Wait() - log.Debug().Msg("Participant sync completed") - return userIDs, levels -} - -func reuploadAvatar(ctx context.Context, intent *appservice.IntentAPI, url string) (id.ContentURI, error) { - getResp, err := http.DefaultClient.Get(url) - if err != nil { - return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err) - } - data, err := io.ReadAll(getResp.Body) - _ = getResp.Body.Close() - if err != nil { - return id.ContentURI{}, fmt.Errorf("failed to read avatar bytes: %w", err) - } - - resp, err := intent.UploadBytes(ctx, data, http.DetectContentType(data)) - if err != nil { - return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err) - } - return resp.ContentURI, nil -} - -func (user *User) reuploadAvatarDirectPath(ctx context.Context, intent *appservice.IntentAPI, directPath string) (id.ContentURI, error) { - data, err := user.Client.DownloadMediaWithPath(directPath, nil, nil, nil, 0, "", "") - if err != nil { - return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err) - } - resp, err := intent.UploadBytes(ctx, data, http.DetectContentType(data)) - if err != nil { - return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err) - } - return resp.ContentURI, nil -} - -func (user *User) updateAvatar(ctx context.Context, jid types.JID, isCommunity bool, avatarID *string, avatarURL *id.ContentURI, avatarSet *bool, intent *appservice.IntentAPI) bool { - currentID := "" - if *avatarSet && *avatarID != "remove" && *avatarID != "unauthorized" { - currentID = *avatarID - } - avatar, err := user.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ - Preview: false, - ExistingID: currentID, - IsCommunity: isCommunity, - }) - log := zerolog.Ctx(ctx) - if errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) { - if *avatarID == "" { - *avatarID = "unauthorized" - *avatarSet = false - return true - } - return false - } else if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) { - avatar = &types.ProfilePictureInfo{ID: "remove"} - if avatar.ID == *avatarID && *avatarSet { - return false - } - *avatarID = avatar.ID - *avatarURL = id.ContentURI{} - return true - } else if err != nil { - log.Err(err).Msg("Failed to get avatar URL") - return false - } else if avatar == nil { - // Avatar hasn't changed - return false - } - if avatar.ID == *avatarID && *avatarSet { - return false - } else if len(avatar.URL) == 0 && len(avatar.DirectPath) == 0 { - log.Warn().Msg("Didn't get URL in response to avatar query") - return false - } else if avatar.ID != *avatarID || avatarURL.IsEmpty() { - var url id.ContentURI - if len(avatar.URL) > 0 { - url, err = reuploadAvatar(ctx, intent, avatar.URL) - if err != nil { - log.Err(err).Msg("Failed to reupload avatar") - return false - } - } else { - url, err = user.reuploadAvatarDirectPath(ctx, intent, avatar.DirectPath) - if err != nil { - log.Err(err).Msg("Failed to reupload avatar") - return false - } - } - *avatarURL = url - } - log.Debug().Str("old_avatar_id", *avatarID).Str("new_avatar_id", avatar.ID).Msg("Updated avatar") - *avatarID = avatar.ID - *avatarSet = false - return true -} - -func (portal *Portal) UpdateNewsletterAvatar(ctx context.Context, user *User, meta *types.NewsletterMetadata) bool { - portal.avatarLock.Lock() - defer portal.avatarLock.Unlock() - var picID string - picture := meta.ThreadMeta.Picture - if picture == nil { - picID = meta.ThreadMeta.Preview.ID - } else { - picID = picture.ID - } - if picID == "" { - picID = "remove" - } - if portal.Avatar == picID && portal.AvatarSet { - return false - } - log := zerolog.Ctx(ctx) - if picID == "remove" { - portal.AvatarURL = id.ContentURI{} - } else if portal.Avatar != picID || portal.AvatarURL.IsEmpty() { - var err error - if picture == nil { - meta, err = user.Client.GetNewsletterInfo(portal.Key.JID) - if err != nil { - log.Err(err).Msg("Failed to fetch full res avatar info for newsletter") - return false - } - picture = meta.ThreadMeta.Picture - if picture == nil { - log.Warn().Msg("Didn't get full res avatar info for newsletter") - return false - } - picID = picture.ID - } - portal.AvatarURL, err = user.reuploadAvatarDirectPath(ctx, portal.MainIntent(), picture.DirectPath) - if err != nil { - log.Err(err).Msg("Failed to reupload newsletter avatar") - return false - } - } - portal.Avatar = picID - portal.AvatarSet = false - return portal.setRoomAvatar(ctx, true, types.EmptyJID, true) -} - -func (portal *Portal) UpdateAvatar(ctx context.Context, user *User, setBy types.JID, updateInfo bool) bool { - if portal.IsNewsletter() { - return false - } - portal.avatarLock.Lock() - defer portal.avatarLock.Unlock() - changed := user.updateAvatar(ctx, portal.Key.JID, portal.IsParent, &portal.Avatar, &portal.AvatarURL, &portal.AvatarSet, portal.MainIntent()) - return portal.setRoomAvatar(ctx, changed, setBy, updateInfo) -} - -func (portal *Portal) setRoomAvatar(ctx context.Context, changed bool, setBy types.JID, updateInfo bool) bool { - log := zerolog.Ctx(ctx) - if !changed || portal.Avatar == "unauthorized" { - if changed || updateInfo { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in setRoomAvatar") - } - } - return changed - } - - if len(portal.MXID) > 0 { - intent := portal.MainIntent() - if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer { - intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) - } - _, err := intent.SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - _, err = portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL) - } - if err != nil { - log.Err(err).Msg("Failed to set room avatar") - return true - } else { - portal.AvatarSet = true - } - } - if updateInfo { - portal.UpdateBridgeInfo(ctx) - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in setRoomAvatar") - } - portal.updateChildRooms(ctx) - } - return true -} - -func (portal *Portal) UpdateName(ctx context.Context, name string, setBy types.JID, updateInfo bool) bool { - if name == "" && portal.IsBroadcastList() { - name = UnnamedBroadcastName - } - if portal.Name == name && (portal.NameSet || len(portal.MXID) == 0 || !portal.shouldSetDMRoomMetadata()) { - return false - } - log := zerolog.Ctx(ctx) - log.Debug().Str("old_name", portal.Name).Str("new_name", name).Msg("Updating room name") - portal.Name = name - portal.NameSet = false - if updateInfo { - defer func() { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after updating name") - } - }() - } - if len(portal.MXID) == 0 { - return true - } - if !portal.shouldSetDMRoomMetadata() { - // TODO only do this if updateInfo? - portal.UpdateBridgeInfo(ctx) - return true - } - intent := portal.MainIntent() - if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer { - intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) - } - _, err := intent.SetRoomName(ctx, portal.MXID, name) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - _, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, name) - } - if err != nil { - log.Err(err).Msg("Failed to set room name") - } else { - portal.NameSet = true - if updateInfo { - portal.UpdateBridgeInfo(ctx) - portal.updateChildRooms(ctx) - } - } - return true -} - -func (portal *Portal) UpdateTopic(ctx context.Context, topic string, setBy types.JID, updateInfo bool) bool { - if portal.Topic == topic && (portal.TopicSet || len(portal.MXID) == 0) { - return false - } - log := zerolog.Ctx(ctx) - log.Debug().Str("old_topic", portal.Topic).Str("new_topic", topic).Msg("Updating topic") - portal.Topic = topic - portal.TopicSet = false - if updateInfo { - defer func() { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after updating topic") - } - }() - } - - intent := portal.MainIntent() - if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer { - intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) - } - _, err := intent.SetRoomTopic(ctx, portal.MXID, topic) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - _, err = portal.MainIntent().SetRoomTopic(ctx, portal.MXID, topic) - } - if err != nil { - log.Err(err).Msg("Failed to set room topic") - } else { - portal.TopicSet = true - if updateInfo { - portal.UpdateBridgeInfo(ctx) - } - } - return true -} - -func newsletterToGroupInfo(meta *types.NewsletterMetadata) *types.GroupInfo { - var out types.GroupInfo - out.JID = meta.ID - out.Name = meta.ThreadMeta.Name.Text - out.NameSetAt = meta.ThreadMeta.Name.UpdateTime.Time - out.Topic = meta.ThreadMeta.Description.Text - out.TopicSetAt = meta.ThreadMeta.Description.UpdateTime.Time - out.TopicID = meta.ThreadMeta.Description.ID - out.GroupCreated = meta.ThreadMeta.CreationTime.Time - out.IsAnnounce = true - out.IsLocked = true - out.IsIncognito = true - return &out -} - -func (portal *Portal) UpdateParentGroup(ctx context.Context, source *User, parent types.JID, updateInfo bool) bool { - portal.parentGroupUpdateLock.Lock() - defer portal.parentGroupUpdateLock.Unlock() - if portal.ParentGroup != parent { - zerolog.Ctx(ctx).Debug(). - Stringer("old_parent_group", portal.ParentGroup). - Stringer("new_parent_group", parent). - Msg("Updating parent group") - portal.updateCommunitySpace(ctx, source, false, false) - portal.ParentGroup = parent - portal.parentPortal = nil - portal.InSpace = false - portal.updateCommunitySpace(ctx, source, true, false) - if updateInfo { - portal.UpdateBridgeInfo(ctx) - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating parent group") - } - } - return true - } else if !portal.ParentGroup.IsEmpty() && !portal.InSpace { - return portal.updateCommunitySpace(ctx, source, true, updateInfo) - } - return false -} - -func (portal *Portal) UpdateMetadata(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata) bool { - if portal.IsPrivateChat() { - return false - } else if portal.IsStatusBroadcastList() { - update := false - update = portal.UpdateName(ctx, StatusBroadcastName, types.EmptyJID, false) || update - update = portal.UpdateTopic(ctx, StatusBroadcastTopic, types.EmptyJID, false) || update - return update - } else if portal.IsBroadcastList() { - update := false - //broadcastMetadata, err := user.Conn.GetBroadcastMetadata(portal.Key.JID) - //if err == nil && broadcastMetadata.Status == 200 { - // portal.SyncBroadcastRecipients(user, broadcastMetadata) - // update = portal.UpdateName(broadcastMetadata.Name, "", nil, false) || update - //} else { - // user.Conn.Store.ContactsLock.RLock() - // contact, _ := user.Conn.Store.Contacts[portal.Key.JID] - // user.Conn.Store.ContactsLock.RUnlock() - // update = portal.UpdateName(contact.Name, "", nil, false) || update - //} - //update = portal.UpdateTopic(BroadcastTopic, "", nil, false) || update - return update - } - if groupInfo == nil && portal.IsNewsletter() { - if newsletterMetadata == nil { - var err error - newsletterMetadata, err = user.Client.GetNewsletterInfo(portal.Key.JID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get newsletter info") - return false - } - } - groupInfo = newsletterToGroupInfo(newsletterMetadata) - } - if groupInfo == nil { - var err error - groupInfo, err = user.Client.GetGroupInfo(portal.Key.JID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get group info") - return false - } - } - - portal.SyncParticipants(ctx, user, groupInfo) - update := false - update = portal.UpdateName(ctx, groupInfo.Name, groupInfo.NameSetBy, false) || update - update = portal.UpdateTopic(ctx, groupInfo.Topic, groupInfo.TopicSetBy, false) || update - update = portal.UpdateParentGroup(ctx, user, groupInfo.LinkedParentJID, false) || update - if portal.ExpirationTime != groupInfo.DisappearingTimer { - update = true - portal.ExpirationTime = groupInfo.DisappearingTimer - } - if portal.IsParent != groupInfo.IsParent { - if portal.MXID != "" { - zerolog.Ctx(ctx).Warn().Bool("new_is_parent", groupInfo.IsParent).Msg("Existing group changed is_parent status") - } - portal.IsParent = groupInfo.IsParent - update = true - } - - portal.RestrictMessageSending(ctx, groupInfo.IsAnnounce) - portal.RestrictMetadataChanges(ctx, groupInfo.IsLocked) - if newsletterMetadata != nil && newsletterMetadata.ViewerMeta != nil { - portal.PromoteNewsletterUser(ctx, user, newsletterMetadata.ViewerMeta.Role) - } - - return update -} - -func (portal *Portal) ensureUserInvited(ctx context.Context, user *User) bool { - return user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) -} - -func (portal *Portal) UpdateMatrixRoom(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata) bool { - if len(portal.MXID) == 0 { - return false - } - log := zerolog.Ctx(ctx).With(). - Str("action", "update matrix room"). - Str("portal_key", portal.Key.String()). - Stringer("source_mxid", user.MXID). - Logger() - ctx = log.WithContext(ctx) - log.Info().Msg("Syncing portal") - - portal.ensureUserInvited(ctx, user) - go portal.addToPersonalSpace(ctx, user) - - if groupInfo == nil && newsletterMetadata != nil { - groupInfo = newsletterToGroupInfo(newsletterMetadata) - } - - update := false - update = portal.UpdateMetadata(ctx, user, groupInfo, newsletterMetadata) || update - if !portal.IsPrivateChat() && !portal.IsBroadcastList() && !portal.IsNewsletter() { - update = portal.UpdateAvatar(ctx, user, types.EmptyJID, false) || update - } else if newsletterMetadata != nil { - update = portal.UpdateNewsletterAvatar(ctx, user, newsletterMetadata) || update - } - if update || portal.LastSync.Add(24*time.Hour).Before(time.Now()) { - portal.LastSync = time.Now() - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after updating") - } - portal.UpdateBridgeInfo(ctx) - portal.updateChildRooms(ctx) - } - return true -} - -func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { - anyone := 0 - nope := 99 - invite := 50 - if portal.bridge.Config.Bridge.AllowUserInvite { - invite = 0 - } - return &event.PowerLevelsEventContent{ - UsersDefault: anyone, - EventsDefault: anyone, - RedactPtr: &anyone, - StateDefaultPtr: &nope, - BanPtr: &nope, - InvitePtr: &invite, - Users: map[id.UserID]int{ - portal.MainIntent().UserID: 100, - portal.bridge.Bot.UserID: 100, - }, - Events: map[string]int{ - event.StateRoomName.Type: anyone, - event.StateRoomAvatar.Type: anyone, - event.StateTopic.Type: anyone, - event.EventReaction.Type: anyone, - event.EventRedaction.Type: anyone, - TypeMSC3381PollResponse.Type: anyone, - }, - } -} - -func (portal *Portal) applyPowerLevelFixes(levels *event.PowerLevelsEventContent) bool { - changed := false - changed = levels.EnsureEventLevel(event.EventReaction, 0) || changed - changed = levels.EnsureEventLevel(event.EventRedaction, 0) || changed - changed = levels.EnsureEventLevel(TypeMSC3381PollResponse, 0) || changed - if portal.IsPrivateChat() { - changed = levels.EnsureUserLevel(portal.bridge.Bot.UserID, 100) || changed - } - return changed -} - -func (portal *Portal) ChangeAdminStatus(ctx context.Context, jids []types.JID, setAdmin bool) id.EventID { - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - newLevel := 0 - if setAdmin { - newLevel = 50 - } - changed := portal.applyPowerLevelFixes(levels) - for _, jid := range jids { - if jid.Server != types.DefaultUserServer { - // TODO handle lids - continue - } - puppet := portal.bridge.GetPuppetByJID(jid) - changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed - - user := portal.bridge.GetUserByJID(jid) - if user != nil { - changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed - } - } - if changed { - resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels") - } else { - return resp.EventID - } - } - return "" -} - -func (portal *Portal) RestrictMessageSending(ctx context.Context, restrict bool) id.EventID { - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - - newLevel := 0 - if restrict { - newLevel = 50 - } - - changed := portal.applyPowerLevelFixes(levels) - if levels.EventsDefault == newLevel && !changed { - return "" - } - - levels.EventsDefault = newLevel - resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels") - return "" - } else { - return resp.EventID - } -} - -func (portal *Portal) PromoteNewsletterUser(ctx context.Context, user *User, role types.NewsletterRole) id.EventID { - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - - newLevel := 0 - switch role { - case types.NewsletterRoleAdmin: - newLevel = 50 - case types.NewsletterRoleOwner: - newLevel = 95 - } - - changed := portal.applyPowerLevelFixes(levels) - changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed - if !changed { - return "" - } - - resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels") - return "" - } else { - return resp.EventID - } -} - -func (portal *Portal) RestrictMetadataChanges(ctx context.Context, restrict bool) id.EventID { - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - newLevel := 0 - if restrict { - newLevel = 50 - } - changed := portal.applyPowerLevelFixes(levels) - changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed - changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed - if changed { - resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels") - } else { - return resp.EventID - } - } - return "" -} - -func (portal *Portal) getBridgeInfoStateKey() string { - return fmt.Sprintf("net.maunium.whatsapp://whatsapp/%s", portal.Key.JID) -} - -func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: "whatsapp", - DisplayName: "WhatsApp", - AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - ExternalURL: "https://www.whatsapp.com/", - }, - Channel: event.BridgeInfoSection{ - ID: portal.Key.JID.String(), - DisplayName: portal.Name, - AvatarURL: portal.AvatarURL.CUString(), - }, - } - if parent := portal.GetParentPortal(); parent != nil { - bridgeInfo.Network = &event.BridgeInfoSection{ - ID: parent.Key.JID.String(), - DisplayName: parent.Name, - AvatarURL: parent.AvatarURL.CUString(), - } - } - return portal.getBridgeInfoStateKey(), bridgeInfo -} - -func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { - log := zerolog.Ctx(ctx) - if len(portal.MXID) == 0 { - log.Debug().Msg("Not updating bridge info: no Matrix room created") - return - } - log.Debug().Msg("Updating bridge info...") - stateKey, content := portal.getBridgeInfo() - _, err := portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateBridge, stateKey, content) - if err != nil { - log.Warn().Err(err).Msg("Failed to update m.bridge info") - } - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateHalfShotBridge, stateKey, content) - if err != nil { - log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge info") - } -} - -func (portal *Portal) updateChildRooms(ctx context.Context) { - if !portal.IsParent { - return - } - children := portal.bridge.GetAllByParentGroup(portal.Key.JID) - for _, child := range children { - changed := child.updateCommunitySpace(ctx, nil, true, false) - // TODO set updateInfo to true instead of updating manually? - child.UpdateBridgeInfo(ctx) - if changed { - // TODO is this saving the wrong portal? - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating") - } - } - } -} - -func (portal *Portal) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") -} - -func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { - evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} - if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { - evt.RotationPeriodMillis = rot.Milliseconds - evt.RotationPeriodMessages = rot.Messages - } - return -} - -func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata, isFullInfo, backfill bool) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if len(portal.MXID) > 0 { - return nil - } - log := zerolog.Ctx(ctx).With(). - Str("action", "create matrix room"). - Str("portal_key", portal.Key.String()). - Stringer("source_mxid", user.MXID). - Logger() - ctx = log.WithContext(ctx) - - intent := portal.MainIntent() - if err := intent.EnsureRegistered(ctx); err != nil { - return err - } - - log.Info().Msg("Creating Matrix room") - - //var broadcastMetadata *types.BroadcastListInfo - if portal.IsPrivateChat() { - puppet := portal.bridge.GetPuppetByJID(portal.Key.JID) - puppet.SyncContact(ctx, user, true, false, "creating private chat portal") - portal.Name = puppet.Displayname - portal.AvatarURL = puppet.AvatarURL - portal.Avatar = puppet.Avatar - portal.Topic = PrivateChatTopic - } else if portal.IsStatusBroadcastList() { - if !portal.bridge.Config.Bridge.EnableStatusBroadcast { - log.Debug().Msg("Status bridging is disabled in config, not creating room after all") - return ErrStatusBroadcastDisabled - } - portal.Name = StatusBroadcastName - portal.Topic = StatusBroadcastTopic - } else if portal.IsBroadcastList() { - //var err error - //broadcastMetadata, err = user.Conn.GetBroadcastMetadata(portal.Key.JID) - //if err == nil && broadcastMetadata.Status == 200 { - // portal.Name = broadcastMetadata.Name - //} else { - // user.Conn.Store.ContactsLock.RLock() - // contact, _ := user.Conn.Store.Contacts[portal.Key.JID] - // user.Conn.Store.ContactsLock.RUnlock() - // portal.Name = contact.Name - //} - //if len(portal.Name) == 0 { - // portal.Name = UnnamedBroadcastName - //} - //portal.Topic = BroadcastTopic - log.Debug().Msg("Broadcast list is not yet supported, not creating room after all") - return fmt.Errorf("broadcast list bridging is currently not supported") - } else { - if portal.IsNewsletter() { - if newsletterMetadata == nil { - var err error - newsletterMetadata, err = user.Client.GetNewsletterInfo(portal.Key.JID) - if err != nil { - return err - } - } - if groupInfo == nil { - groupInfo = newsletterToGroupInfo(newsletterMetadata) - } - } else if groupInfo == nil || !isFullInfo { - foundInfo, err := user.Client.GetGroupInfo(portal.Key.JID) - - // Ensure that the user is actually a participant in the conversation - // before creating the matrix room - if errors.Is(err, whatsmeow.ErrNotInGroup) { - log.Debug().Msg("Skipping creating room because the user is not a participant") - err = user.bridge.DB.BackfillQueue.DeleteAllForPortal(ctx, user.MXID, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to delete backfill queue for portal") - } - err = user.bridge.DB.HistorySync.DeleteAllMessagesForPortal(ctx, user.MXID, portal.Key) - if err != nil { - log.Err(err).Msg("Failed to delete historical messages for portal") - } - return err - } else if err != nil { - log.Err(err).Msg("Failed to get group info") - } else { - groupInfo = foundInfo - isFullInfo = true - } - } - if groupInfo != nil { - portal.Name = groupInfo.Name - portal.Topic = groupInfo.Topic - portal.IsParent = groupInfo.IsParent - portal.ParentGroup = groupInfo.LinkedParentJID - if groupInfo.IsEphemeral { - portal.ExpirationTime = groupInfo.DisappearingTimer - } - } - if portal.IsNewsletter() { - portal.UpdateNewsletterAvatar(ctx, user, newsletterMetadata) - } else { - portal.UpdateAvatar(ctx, user, types.EmptyJID, false) - } - } - - powerLevels := portal.GetBasePowerLevels() - - if groupInfo != nil { - if groupInfo.IsAnnounce { - powerLevels.EventsDefault = 50 - } - if groupInfo.IsLocked { - powerLevels.EnsureEventLevel(event.StateRoomName, 50) - powerLevels.EnsureEventLevel(event.StateRoomAvatar, 50) - powerLevels.EnsureEventLevel(event.StateTopic, 50) - } - } - if newsletterMetadata != nil && newsletterMetadata.ViewerMeta != nil { - switch newsletterMetadata.ViewerMeta.Role { - case types.NewsletterRoleAdmin: - powerLevels.EnsureUserLevel(user.MXID, 50) - case types.NewsletterRoleOwner: - powerLevels.EnsureUserLevel(user.MXID, 95) - } - } - - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - - initialState := []*event.Event{{ - Type: event.StatePowerLevels, - Content: event.Content{ - Parsed: powerLevels, - }, - }, { - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - var invite []id.UserID - if portal.bridge.Config.Bridge.Encryption.Default { - initialState = append(initialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: portal.GetEncryptionEventContent(), - }, - }) - portal.Encrypted = true - if portal.IsPrivateChat() { - invite = append(invite, portal.bridge.Bot.UserID) - } - } - if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL.CUString()}, - }, - }) - portal.AvatarSet = true - } else { - portal.AvatarSet = false - } - - creationContent := make(map[string]interface{}) - if !portal.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - if portal.IsParent { - creationContent["type"] = event.RoomTypeSpace - } else if parent := portal.GetParentPortal(); parent != nil && parent.MXID != "" { - initialState = append(initialState, &event.Event{ - Type: event.StateSpaceParent, - StateKey: proto.String(parent.MXID.String()), - Content: event.Content{ - Parsed: &event.SpaceParentEventContent{ - Via: []string{portal.bridge.Config.Homeserver.Domain}, - Canonical: true, - }, - }, - }) - } - autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) - if autoJoinInvites { - log.Debug().Msg("Hungryserv mode: adding all group members in create request") - if groupInfo != nil && !portal.IsNewsletter() { - // TODO non-hungryserv could also include all members in invites, and then send joins manually? - participants, powerLevels := portal.SyncParticipants(ctx, user, groupInfo) - invite = append(invite, participants...) - if initialState[0].Type != event.StatePowerLevels { - panic(fmt.Errorf("unexpected type %s in first initial state event", initialState[0].Type.Type)) - } - initialState[0].Content.Parsed = powerLevels - } else { - invite = append(invite, user.MXID) - } - } - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: portal.Name, - Topic: portal.Topic, - Invite: invite, - Preset: "private_chat", - IsDirect: portal.IsPrivateChat(), - InitialState: initialState, - CreationContent: creationContent, - - BeeperAutoJoinInvites: autoJoinInvites, - } - if !portal.shouldSetDMRoomMetadata() { - req.Name = "" - } - legacyBackfill := user.bridge.Config.Bridge.HistorySync.Backfill && backfill && !user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) - var backfillStarted bool - if legacyBackfill { - portal.latestEventBackfillLock.Lock() - defer func() { - if !backfillStarted { - portal.latestEventBackfillLock.Unlock() - } - }() - } - resp, err := intent.CreateRoom(ctx, req) - if err != nil { - return err - } - log.Info().Stringer("room_id", resp.RoomID).Msg("Matrix room created") - portal.InSpace = false - portal.NameSet = len(req.Name) > 0 - portal.TopicSet = len(req.Topic) > 0 - portal.MXID = resp.RoomID - portal.updateLogger() - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - err = portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after creating room") - } - - // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here. - inviteMembership := event.MembershipInvite - if autoJoinInvites { - inviteMembership = event.MembershipJoin - } - for _, userID := range invite { - err = portal.bridge.StateStore.SetMembership(ctx, portal.MXID, userID, inviteMembership) - if err != nil { - log.Err(err).Stringer("user_id", userID).Msg("Failed to update membership in state store") - } - } - - if !autoJoinInvites { - portal.ensureUserInvited(ctx, user) - } - user.syncChatDoublePuppetDetails(ctx, portal, true) - - go portal.updateCommunitySpace(ctx, user, true, true) - go portal.addToPersonalSpace(ctx, user) - - if !portal.IsNewsletter() && groupInfo != nil && !autoJoinInvites { - portal.SyncParticipants(ctx, user, groupInfo) - } - //if broadcastMetadata != nil { - // portal.SyncBroadcastRecipients(user, broadcastMetadata) - //} - if portal.IsPrivateChat() { - puppet := user.bridge.GetPuppetByJID(portal.Key.JID) - - if portal.bridge.Config.Bridge.Encryption.Default { - err = portal.bridge.Bot.EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Failed to ensure bridge bot is joined to created portal") - } - } - - user.UpdateDirectChats(ctx, map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}) - } else if portal.IsParent { - portal.updateChildRooms(ctx) - } - - if user.bridge.Config.Bridge.HistorySync.Backfill && backfill { - if legacyBackfill { - backfillStarted = true - go portal.legacyBackfill(context.WithoutCancel(ctx), user) - } else { - portals := []*Portal{portal} - user.EnqueueImmediateBackfills(ctx, portals) - user.EnqueueDeferredBackfills(ctx, portals) - user.BackfillQueue.ReCheck() - } - } - return nil -} - -func (portal *Portal) addToPersonalSpace(ctx context.Context, user *User) { - spaceID := user.GetSpaceRoom(ctx) - if len(spaceID) == 0 || user.IsInSpace(ctx, portal.Key) { - return - } - _, err := portal.bridge.Bot.SendStateEvent(ctx, spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{portal.bridge.Config.Homeserver.Domain}, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("space_id", spaceID).Msg("Failed to add portal to user's personal filtering space") - } else { - zerolog.Ctx(ctx).Debug().Stringer("space_id", spaceID).Msg("Added portal to user's personal filtering space") - user.MarkInSpace(ctx, portal.Key) - } -} - -func (portal *Portal) removeSpaceParentEvent(space id.RoomID) { - _, err := portal.MainIntent().SendStateEvent(context.TODO(), portal.MXID, event.StateSpaceParent, space.String(), &event.SpaceParentEventContent{}) - if err != nil { - portal.zlog.Err(err).Stringer("space_mxid", space).Msg("Failed to send m.space.parent event to remove portal from space") - } -} - -func (portal *Portal) updateCommunitySpace(ctx context.Context, user *User, add, updateInfo bool) bool { - if add == portal.InSpace { - return false - } - // TODO if this function is changed to use the context logger, updateChildRooms should add the child portal info to the logger - log := portal.zlog.With().Stringer("room_id", portal.MXID).Logger() - space := portal.GetParentPortal() - if space == nil { - return false - } else if space.MXID == "" { - if !add || user == nil { - return false - } - log.Debug().Stringer("parent_group_jid", space.Key.JID).Msg("Creating portal for parent group") - err := space.CreateMatrixRoom(ctx, user, nil, nil, false, false) - if err != nil { - log.Err(err).Msg("Failed to create portal for parent group") - return false - } - } - - var parentContent event.SpaceParentEventContent - var childContent event.SpaceChildEventContent - if add { - parentContent.Canonical = true - parentContent.Via = []string{portal.bridge.Config.Homeserver.Domain} - childContent.Via = []string{portal.bridge.Config.Homeserver.Domain} - log.Debug(). - Stringer("space_mxid", space.MXID). - Stringer("parent_group_jid", space.Key.JID). - Msg("Adding room to parent group space") - } else { - log.Debug(). - Stringer("space_mxid", space.MXID). - Stringer("parent_group_jid", space.Key.JID). - Msg("Removing room from parent group space") - } - - _, err := space.MainIntent().SendStateEvent(ctx, space.MXID, event.StateSpaceChild, portal.MXID.String(), &childContent) - if err != nil { - log.Err(err).Stringer("space_mxid", space.MXID).Msg("Failed to send m.space.child event") - return false - } - _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateSpaceParent, space.MXID.String(), &parentContent) - if err != nil { - log.Err(err).Stringer("space_mxid", space.MXID).Msg("Failed to send m.space.parent event") - } - portal.InSpace = add - if updateInfo { - err = portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal after updating parent space") - } - portal.UpdateBridgeInfo(ctx) - } - return true -} - -func (portal *Portal) IsPrivateChat() bool { - return portal.Key.JID.Server == types.DefaultUserServer -} - -func (portal *Portal) IsGroupChat() bool { - return portal.Key.JID.Server == types.GroupServer -} - -func (portal *Portal) IsBroadcastList() bool { - return portal.Key.JID.Server == types.BroadcastServer -} - -func (portal *Portal) IsNewsletter() bool { - return portal.Key.JID.Server == types.NewsletterServer -} - -func (portal *Portal) IsStatusBroadcastList() bool { - return portal.Key.JID == types.StatusBroadcastJID -} - -func (portal *Portal) HasRelaybot() bool { - return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0 -} - -func (portal *Portal) GetRelayUser() *User { - if !portal.HasRelaybot() { - return nil - } else if portal.relayUser == nil { - portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID) - } - return portal.relayUser -} - -func (portal *Portal) GetParentPortal() *Portal { - if portal.ParentGroup.IsEmpty() { - return nil - } else if portal.parentPortal == nil { - portal.parentPortal = portal.bridge.GetPortalByJID(database.NewPortalKey(portal.ParentGroup, portal.ParentGroup)) - } - return portal.parentPortal -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - if portal.IsPrivateChat() { - return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent() - } - return portal.bridge.Bot -} - -func (portal *Portal) addReplyMention(content *event.MessageEventContent, sender types.JID, senderMXID id.UserID) { - if content.Mentions == nil || (sender.IsEmpty() && senderMXID == "") { - return - } - // TODO handle lids - if senderMXID == "" && sender.Server == types.DefaultUserServer { - if user := portal.bridge.GetUserByJID(sender); user != nil { - senderMXID = user.MXID - } else { - puppet := portal.bridge.GetPuppetByJID(sender) - senderMXID = puppet.MXID - } - } - if senderMXID != "" && !slices.Contains(content.Mentions.UserIDs, senderMXID) { - content.Mentions.UserIDs = append(content.Mentions.UserIDs, senderMXID) - } -} - -func (portal *Portal) SetReply(ctx context.Context, content *event.MessageEventContent, replyTo *ReplyInfo, isHungryBackfill bool) bool { - if replyTo == nil { - return false - } - log := zerolog.Ctx(ctx).With(). - Object("reply_to", replyTo). - Str("action", "SetReply"). - Logger() - key := portal.Key - targetPortal := portal - defer func() { - if content.RelatesTo != nil && content.RelatesTo.InReplyTo != nil && targetPortal != portal { - content.RelatesTo.InReplyTo.UnstableRoomID = targetPortal.MXID - } - }() - if portal.bridge.Config.Bridge.CrossRoomReplies && !replyTo.Chat.IsEmpty() && replyTo.Chat != key.JID { - if replyTo.Chat.Server == types.GroupServer { - key = database.NewPortalKey(replyTo.Chat, types.EmptyJID) - } else if replyTo.Chat == types.StatusBroadcastJID { - key = database.NewPortalKey(replyTo.Chat, key.Receiver) - } - if key != portal.Key { - targetPortal = portal.bridge.GetExistingPortalByJID(key) - if targetPortal == nil { - return false - } - } - } - message, err := portal.bridge.DB.Message.GetByJID(ctx, key, replyTo.MessageID) - if err != nil { - log.Err(err).Msg("Failed to get reply target from database") - return false - } else if message == nil || message.IsFakeMXID() { - if isHungryBackfill { - content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(targetPortal.deterministicEventID(replyTo.Sender, replyTo.MessageID, "")) - portal.addReplyMention(content, replyTo.Sender, "") - return true - } else { - log.Warn().Msg("Failed to find reply target") - } - return false - } - portal.addReplyMention(content, message.Sender, message.SenderMXID) - content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(message.MXID) - if portal.bridge.Config.Bridge.DisableReplyFallbacks { - return true - } - evt, err := targetPortal.MainIntent().GetEvent(ctx, targetPortal.MXID, message.MXID) - if err != nil { - log.Warn().Err(err).Msg("Failed to get reply target event") - return true - } - _ = evt.Content.ParseRaw(evt.Type) - if evt.Type == event.EventEncrypted { - decryptedEvt, err := portal.bridge.Crypto.Decrypt(ctx, evt) - if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt reply target event") - } else { - evt = decryptedEvt - } - } - content.SetReply(evt) - return true -} - -func (portal *Portal) HandleMessageReaction(ctx context.Context, intent *appservice.IntentAPI, user *User, info *types.MessageInfo, reaction *waProto.ReactionMessage, existingMsg *database.Message) { - if existingMsg != nil { - _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ - Reason: "The undecryptable message was actually a reaction", - }) - } - - targetJID := reaction.GetKey().GetID() - log := zerolog.Ctx(ctx).With(). - Str("reaction_target_id", targetJID). - Logger() - if reaction.GetText() == "" { - existing, err := portal.bridge.DB.Reaction.GetByTargetJID(ctx, portal.Key, targetJID, info.Sender) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction to remove") - return - } else if existing == nil { - log.Debug().Msg("Dropping removal of unknown reaction") - return - } - - resp, err := intent.RedactEvent(ctx, portal.MXID, existing.MXID) - if err != nil { - log.Err(err). - Stringer("reaction_mxid", existing.MXID). - Msg("Failed to redact reaction") - } - portal.finishHandling(ctx, existingMsg, info, resp.EventID, intent.UserID, database.MsgReaction, 0, database.MsgNoError) - err = existing.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete reaction from database") - } - } else { - target, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, targetJID) - if err != nil { - log.Err(err).Msg("Failed to get reaction target message from database") - return - } else if target == nil { - log.Debug().Msg("Dropping reaction to unknown message") - return - } - - var content event.ReactionEventContent - content.RelatesTo = event.RelatesTo{ - Type: event.RelAnnotation, - EventID: target.MXID, - Key: variationselector.Add(reaction.GetText()), - } - resp, err := intent.SendMassagedMessageEvent(ctx, portal.MXID, event.EventReaction, &content, info.Timestamp.UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to bridge reaction") - return - } - - portal.finishHandling(ctx, existingMsg, info, resp.EventID, intent.UserID, database.MsgReaction, 0, database.MsgNoError) - portal.upsertReaction(ctx, intent, target.JID, info.Sender, resp.EventID, info.ID) - } -} - -func (portal *Portal) HandleMessageRevoke(ctx context.Context, user *User, info *types.MessageInfo, key *waProto.MessageKey) bool { - log := zerolog.Ctx(ctx).With().Str("revoke_target_id", key.GetId()).Logger() - msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, key.GetId()) - if err != nil { - log.Err(err).Msg("Failed to get revoke target message from database") - return false - } else if msg == nil || msg.IsFakeMXID() { - return false - } - intent := portal.bridge.GetPuppetByJID(info.Sender).IntentFor(portal) - _, err = intent.RedactEvent(ctx, portal.MXID, msg.MXID) - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, msg.MXID) - } - if err != nil { - log.Err(err).Stringer("revoke_target_mxid", msg.MXID).Msg("Failed to redact message from revoke") - } else if err = msg.Delete(ctx); err != nil { - log.Err(err).Msg("Failed to delete message from database after revoke") - } - return true -} - -func (portal *Portal) deleteForMe(ctx context.Context, user *User, content *events.DeleteForMe) bool { - matrixUsers, err := portal.GetMatrixUsers(ctx) - if err != nil { - portal.zlog.Err(err).Msg("Failed to get Matrix users in portal to see if DeleteForMe should be handled") - return false - } - if len(matrixUsers) == 1 && matrixUsers[0] == user.MXID { - msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, content.MessageID) - if msg == nil || msg.IsFakeMXID() { - return false - } - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, msg.MXID) - if err != nil { - portal.zlog.Err(err).Str("message_id", msg.JID).Msg("Failed to redact message from DeleteForMe") - } else if err = msg.Delete(ctx); err != nil { - portal.zlog.Err(err).Str("message_id", msg.JID).Msg("Failed to delete message from database after DeleteForMe") - } - return true - } - return false -} - -func (portal *Portal) sendMainIntentMessage(ctx context.Context, content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { - return portal.sendMessage(ctx, portal.MainIntent(), event.EventMessage, content, nil, 0) -} - -func (portal *Portal) encrypt(ctx context.Context, intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { - if !portal.Encrypted || portal.bridge.Crypto == nil { - return eventType, nil - } - intent.AddDoublePuppetValue(content) - // TODO maybe the locking should be inside mautrix-go? - portal.encryptLock.Lock() - defer portal.encryptLock.Unlock() - err := portal.bridge.Crypto.Encrypt(ctx, portal.MXID, eventType, content) - if err != nil { - return eventType, fmt.Errorf("failed to encrypt event: %w", err) - } - return event.EventEncrypted, nil -} - -func (portal *Portal) sendMessage(ctx context.Context, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := event.Content{Parsed: content, Raw: extraContent} - var err error - eventType, err = portal.encrypt(ctx, intent, &wrappedContent, eventType) - if err != nil { - return nil, err - } - - _, _ = intent.UserTyping(ctx, portal.MXID, false, 0) - if timestamp == 0 { - return intent.SendMessageEvent(ctx, portal.MXID, eventType, &wrappedContent) - } else { - return intent.SendMassagedMessageEvent(ctx, portal.MXID, eventType, &wrappedContent, timestamp) - } -} - -type ReplyInfo struct { - MessageID types.MessageID - Chat types.JID - Sender types.JID -} - -func (r *ReplyInfo) Equals(other *ReplyInfo) bool { - if r == nil { - return other == nil - } else if other == nil { - return false - } - return r.MessageID == other.MessageID && r.Chat == other.Chat && r.Sender == other.Sender -} - -func (r ReplyInfo) MarshalZerologObject(e *zerolog.Event) { - e.Str("message_id", r.MessageID) - e.Str("chat_jid", r.Chat.String()) - e.Str("sender_jid", r.Sender.String()) -} - -type Replyable interface { - GetStanzaID() string - GetParticipant() string - GetRemoteJid() string -} - -func GetReply(replyable Replyable) *ReplyInfo { - if replyable.GetStanzaID() == "" { - return nil - } - sender, err := types.ParseJID(replyable.GetParticipant()) - if err != nil { - return nil - } - chat, _ := types.ParseJID(replyable.GetRemoteJid()) - return &ReplyInfo{ - MessageID: types.MessageID(replyable.GetStanzaID()), - Chat: chat, - Sender: sender, - } -} - -type ConvertedMessage struct { - Intent *appservice.IntentAPI - Type event.Type - Content *event.MessageEventContent - Extra map[string]interface{} - Caption *event.MessageEventContent - - MultiEvent []*event.MessageEventContent - - ReplyTo *ReplyInfo - ExpiresIn time.Duration - Error database.MessageErrorType - MediaKey []byte -} - -func (cm *ConvertedMessage) MergeCaption() { - if cm.Caption == nil { - return - } - cm.Content.FileName = cm.Content.Body - extensibleCaption := map[string]interface{}{ - "org.matrix.msc1767.text": cm.Caption.Body, - } - cm.Extra["org.matrix.msc1767.caption"] = extensibleCaption - cm.Content.Body = cm.Caption.Body - if cm.Caption.Format == event.FormatHTML { - cm.Content.Format = event.FormatHTML - cm.Content.FormattedBody = cm.Caption.FormattedBody - extensibleCaption["org.matrix.msc1767.html"] = cm.Caption.FormattedBody - } - cm.Caption = nil -} -func (portal *Portal) convertTextMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.Message) *ConvertedMessage { - content := &event.MessageEventContent{ - Body: msg.GetConversation(), - MsgType: event.MsgText, - } - if len(msg.GetExtendedTextMessage().GetText()) > 0 { - content.Body = msg.GetExtendedTextMessage().GetText() - } - - contextInfo := msg.GetExtendedTextMessage().GetContextInfo() - portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, content, contextInfo.GetMentionedJID(), false, false) - expiresIn := time.Duration(contextInfo.GetExpiration()) * time.Second - extraAttrs := map[string]interface{}{} - extraAttrs["com.beeper.linkpreviews"] = portal.convertURLPreviewToBeeper(ctx, intent, source, msg.GetExtendedTextMessage()) - - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: content, - ReplyTo: GetReply(contextInfo), - ExpiresIn: expiresIn, - Extra: extraAttrs, - } -} - -func (portal *Portal) convertTemplateMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, tplMsg *waProto.TemplateMessage) *ConvertedMessage { - converted := &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: "Unsupported business message", - MsgType: event.MsgText, - }, - ReplyTo: GetReply(tplMsg.GetContextInfo()), - ExpiresIn: time.Duration(tplMsg.GetContextInfo().GetExpiration()) * time.Second, - } - - tpl := tplMsg.GetHydratedTemplate() - if tpl == nil { - return converted - } - content := tpl.GetHydratedContentText() - if buttons := tpl.GetHydratedButtons(); len(buttons) > 0 { - addButtonText := false - descriptions := make([]string, len(buttons)) - for i, rawButton := range buttons { - switch button := rawButton.GetHydratedButton().(type) { - case *waProto.HydratedTemplateButton_QuickReplyButton: - descriptions[i] = fmt.Sprintf("<%s>", button.QuickReplyButton.GetDisplayText()) - addButtonText = true - case *waProto.HydratedTemplateButton_UrlButton: - descriptions[i] = fmt.Sprintf("[%s](%s)", button.UrlButton.GetDisplayText(), button.UrlButton.GetURL()) - case *waProto.HydratedTemplateButton_CallButton: - descriptions[i] = fmt.Sprintf("[%s](tel:%s)", button.CallButton.GetDisplayText(), button.CallButton.GetPhoneNumber()) - } - } - description := strings.Join(descriptions, " - ") - if addButtonText { - description += "\nUse the WhatsApp app to click buttons" - } - content = fmt.Sprintf("%s\n\n%s", content, description) - } - if footer := tpl.GetHydratedFooterText(); footer != "" { - content = fmt.Sprintf("%s\n\n%s", content, footer) - } - - var convertedTitle *ConvertedMessage - switch title := tpl.GetTitle().(type) { - case *waProto.TemplateMessage_HydratedFourRowTemplate_DocumentMessage: - convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.DocumentMessage, "file attachment", false) - case *waProto.TemplateMessage_HydratedFourRowTemplate_ImageMessage: - convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.ImageMessage, "photo", false) - case *waProto.TemplateMessage_HydratedFourRowTemplate_VideoMessage: - convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.VideoMessage, "video attachment", false) - case *waProto.TemplateMessage_HydratedFourRowTemplate_LocationMessage: - content = fmt.Sprintf("Unsupported location message\n\n%s", content) - case *waProto.TemplateMessage_HydratedFourRowTemplate_HydratedTitleText: - content = fmt.Sprintf("%s\n\n%s", title.HydratedTitleText, content) - } - - converted.Content.Body = content - portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, converted.Content, nil, true, false) - if convertedTitle != nil { - converted.MediaKey = convertedTitle.MediaKey - converted.Extra = convertedTitle.Extra - converted.Caption = converted.Content - converted.Content = convertedTitle.Content - converted.Error = convertedTitle.Error - } - if converted.Extra == nil { - converted.Extra = make(map[string]interface{}) - } - converted.Extra["fi.mau.whatsapp.hydrated_template_id"] = tpl.GetTemplateID() - return converted -} - -func (portal *Portal) convertTemplateButtonReplyMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.TemplateButtonReplyMessage) *ConvertedMessage { - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: msg.GetSelectedDisplayText(), - MsgType: event.MsgText, - }, - Extra: map[string]interface{}{ - "fi.mau.whatsapp.template_button_reply": map[string]interface{}{ - "id": msg.GetSelectedID(), - "index": msg.GetSelectedIndex(), - }, - }, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertListMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.ListMessage) *ConvertedMessage { - converted := &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: "Unsupported business message", - MsgType: event.MsgText, - }, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } - body := msg.GetDescription() - if msg.GetTitle() != "" { - if body == "" { - body = msg.GetTitle() - } else { - body = fmt.Sprintf("%s\n\n%s", msg.GetTitle(), body) - } - } - randomID := random.String(64) - body = fmt.Sprintf("%s\n%s", body, randomID) - if msg.GetFooterText() != "" { - body = fmt.Sprintf("%s\n\n%s", body, msg.GetFooterText()) - } - converted.Content.Body = body - portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, converted.Content, nil, false, true) - - var optionsMarkdown strings.Builder - _, _ = fmt.Fprintf(&optionsMarkdown, "#### %s\n", msg.GetButtonText()) - for _, section := range msg.GetSections() { - nesting := "" - if section.GetTitle() != "" { - _, _ = fmt.Fprintf(&optionsMarkdown, "* %s\n", section.GetTitle()) - nesting = " " - } - for _, row := range section.GetRows() { - if row.GetDescription() != "" { - _, _ = fmt.Fprintf(&optionsMarkdown, "%s* %s: %s\n", nesting, row.GetTitle(), row.GetDescription()) - } else { - _, _ = fmt.Fprintf(&optionsMarkdown, "%s* %s\n", nesting, row.GetTitle()) - } - } - } - optionsMarkdown.WriteString("\nUse the WhatsApp app to respond") - rendered := format.RenderMarkdown(optionsMarkdown.String(), true, false) - converted.Content.Body = strings.Replace(converted.Content.Body, randomID, rendered.Body, 1) - converted.Content.FormattedBody = strings.Replace(converted.Content.FormattedBody, randomID, rendered.FormattedBody, 1) - return converted -} - -func (portal *Portal) convertListResponseMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ListResponseMessage) *ConvertedMessage { - var body string - if msg.GetTitle() != "" { - if msg.GetDescription() != "" { - body = fmt.Sprintf("%s\n\n%s", msg.GetTitle(), msg.GetDescription()) - } else { - body = msg.GetTitle() - } - } else if msg.GetDescription() != "" { - body = msg.GetDescription() - } else { - body = "Unsupported list reply message" - } - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - Body: body, - MsgType: event.MsgText, - }, - Extra: map[string]interface{}{ - "fi.mau.whatsapp.list_reply": map[string]interface{}{ - "row_id": msg.GetSingleSelectReply().GetSelectedRowID(), - }, - }, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertPollUpdateMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg *waProto.PollUpdateMessage) *ConvertedMessage { - log := zerolog.Ctx(ctx).With(). - Str("poll_id", msg.GetPollCreationMessageKey().GetId()). - Logger() - pollMessage, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msg.GetPollCreationMessageKey().GetId()) - if err != nil { - log.Err(err).Msg("Failed to get poll message to convert vote") - return nil - } else if pollMessage == nil { - log.Warn().Msg("Poll message not found for converting vote message") - return nil - } - vote, err := source.Client.DecryptPollVote(&events.Message{ - Info: *info, - Message: &waProto.Message{PollUpdateMessage: msg}, - }) - if err != nil { - log.Err(err).Msg("Failed to decrypt vote message") - return nil - } - selectedHashes := make([]string, len(vote.GetSelectedOptions())) - if pollMessage.Type == database.MsgMatrixPoll { - mappedAnswers, err := pollMessage.GetPollOptionIDs(ctx, vote.GetSelectedOptions()) - if err != nil { - log.Err(err).Msg("Failed to get poll option IDs") - return nil - } - for i, opt := range vote.GetSelectedOptions() { - if len(opt) != 32 { - log.Warn().Int("hash_len", len(opt)).Msg("Unexpected option hash length in vote") - continue - } - var ok bool - selectedHashes[i], ok = mappedAnswers[[32]byte(opt)] - if !ok { - log.Warn().Hex("option_hash", opt).Msg("Didn't find ID for option in vote") - } - } - } else { - for i, opt := range vote.GetSelectedOptions() { - selectedHashes[i] = hex.EncodeToString(opt) - } - } - - evtType := TypeMSC3381PollResponse - //if portal.bridge.Config.Bridge.ExtEvPolls == 2 { - // evtType = TypeMSC3381V2PollResponse - //} - return &ConvertedMessage{ - Intent: intent, - Type: evtType, - Content: &event.MessageEventContent{ - RelatesTo: &event.RelatesTo{ - Type: event.RelReference, - EventID: pollMessage.MXID, - }, - }, - Extra: map[string]any{ - "org.matrix.msc3381.poll.response": map[string]any{ - "answers": selectedHashes, - }, - //"org.matrix.msc3381.v2.selections": selectedHashes, - }, - } -} - -func (portal *Portal) convertPollCreationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.PollCreationMessage) *ConvertedMessage { - optionNames := make([]string, len(msg.GetOptions())) - optionsListText := make([]string, len(optionNames)) - optionsListHTML := make([]string, len(optionNames)) - msc3381Answers := make([]map[string]any, len(optionNames)) - msc3381V2Answers := make([]map[string]any, len(optionNames)) - for i, opt := range msg.GetOptions() { - optionNames[i] = opt.GetOptionName() - optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, optionNames[i]) - optionsListHTML[i] = fmt.Sprintf("
  • %s
  • ", event.TextToHTML(optionNames[i])) - optionHash := sha256.Sum256([]byte(opt.GetOptionName())) - optionHashStr := hex.EncodeToString(optionHash[:]) - msc3381Answers[i] = map[string]any{ - "id": optionHashStr, - "org.matrix.msc1767.text": opt.GetOptionName(), - } - msc3381V2Answers[i] = map[string]any{ - "org.matrix.msc3381.v2.id": optionHashStr, - "org.matrix.msc1767.markup": []map[string]any{ - {"mimetype": "text/plain", "body": opt.GetOptionName()}, - }, - } - } - body := fmt.Sprintf("%s\n\n%s\n\n(This message is a poll. Please open WhatsApp to vote.)", msg.GetName(), strings.Join(optionsListText, "\n")) - formattedBody := fmt.Sprintf("

    %s

      %s

    (This message is a poll. Please open WhatsApp to vote.)

    ", event.TextToHTML(msg.GetName()), strings.Join(optionsListHTML, "")) - maxChoices := int(msg.GetSelectableOptionsCount()) - if maxChoices <= 0 { - maxChoices = len(optionNames) - } - evtType := event.EventMessage - if portal.bridge.Config.Bridge.ExtEvPolls { - evtType = TypeMSC3381PollStart - } - //else if portal.bridge.Config.Bridge.ExtEvPolls == 2 { - // evtType.Type = "org.matrix.msc3381.v2.poll.start" - //} - return &ConvertedMessage{ - Intent: intent, - Type: evtType, - Content: &event.MessageEventContent{ - Body: body, - MsgType: event.MsgText, - Format: event.FormatHTML, - FormattedBody: formattedBody, - }, - Extra: map[string]any{ - // Custom metadata - "fi.mau.whatsapp.poll": map[string]any{ - "option_names": optionNames, - "selectable_options_count": msg.GetSelectableOptionsCount(), - }, - - // Slightly less extensible events (November 2022) - //"org.matrix.msc1767.markup": []map[string]any{ - // {"mimetype": "text/html", "body": formattedBody}, - // {"mimetype": "text/plain", "body": body}, - //}, - //"org.matrix.msc3381.v2.poll": map[string]any{ - // "kind": "org.matrix.msc3381.v2.disclosed", - // "max_selections": maxChoices, - // "question": map[string]any{ - // "org.matrix.msc1767.markup": []map[string]any{ - // {"mimetype": "text/plain", "body": msg.GetName()}, - // }, - // }, - // "answers": msc3381V2Answers, - //}, - - // Legacyest extensible events - "org.matrix.msc1767.message": []map[string]any{ - {"mimetype": "text/html", "body": formattedBody}, - {"mimetype": "text/plain", "body": body}, - }, - "org.matrix.msc3381.poll.start": map[string]any{ - "kind": "org.matrix.msc3381.poll.disclosed", - "max_selections": maxChoices, - "question": map[string]any{ - "org.matrix.msc1767.text": msg.GetName(), - }, - "answers": msc3381Answers, - }, - }, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertLiveLocationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.LiveLocationMessage) *ConvertedMessage { - content := &event.MessageEventContent{ - Body: "Started sharing live location", - MsgType: event.MsgNotice, - } - if len(msg.GetCaption()) > 0 { - content.Body += ": " + msg.GetCaption() - } - content.Body += "\n\nUse the WhatsApp app to see the location." - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: content, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertLocationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.LocationMessage) *ConvertedMessage { - url := msg.GetURL() - if len(url) == 0 { - url = fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()) - } - name := msg.GetName() - if len(name) == 0 { - latChar := 'N' - if msg.GetDegreesLatitude() < 0 { - latChar = 'S' - } - longChar := 'E' - if msg.GetDegreesLongitude() < 0 { - longChar = 'W' - } - name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(msg.GetDegreesLatitude()), latChar, math.Abs(msg.GetDegreesLongitude()), longChar) - } - - content := &event.MessageEventContent{ - MsgType: event.MsgLocation, - Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url), - Format: event.FormatHTML, - FormattedBody: fmt.Sprintf("Location: %s
    %s", url, name, msg.GetAddress()), - GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()), - } - - if len(msg.GetJPEGThumbnail()) > 0 { - thumbnailMime := http.DetectContentType(msg.GetJPEGThumbnail()) - uploadedThumbnail, _ := intent.UploadBytes(ctx, msg.GetJPEGThumbnail(), thumbnailMime) - if uploadedThumbnail != nil { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJPEGThumbnail())) - content.Info = &event.FileInfo{ - ThumbnailInfo: &event.FileInfo{ - Size: len(msg.GetJPEGThumbnail()), - Width: cfg.Width, - Height: cfg.Height, - MimeType: thumbnailMime, - }, - ThumbnailURL: uploadedThumbnail.ContentURI.CUString(), - } - } - } - - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: content, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -const inviteMsg = `%s
    This invitation to join "%s" expires at %s. Reply to this message with !wa accept to accept the invite.` -const inviteMsgBroken = `%s
    This invitation to join "%s" expires at %s. However, the invite message is broken or unsupported and cannot be accepted.` -const inviteMetaField = "fi.mau.whatsapp.invite" -const escapedInviteMetaField = `fi\.mau\.whatsapp\.invite` - -type InviteMeta struct { - JID types.JID `json:"jid"` - Code string `json:"code"` - Expiration int64 `json:"expiration,string"` - Inviter types.JID `json:"inviter"` -} - -func (portal *Portal) convertGroupInviteMessage(ctx context.Context, intent *appservice.IntentAPI, info *types.MessageInfo, msg *waProto.GroupInviteMessage) *ConvertedMessage { - expiry := time.Unix(msg.GetInviteExpiration(), 0) - template := inviteMsg - var extraAttrs map[string]any - groupJID, err := types.ParseJID(msg.GetGroupJID()) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("invite_group_jid", msg.GetGroupJID()).Msg("Failed to parse invite group JID") - template = inviteMsgBroken - } else { - extraAttrs = map[string]interface{}{ - inviteMetaField: InviteMeta{ - JID: groupJID, - Code: msg.GetInviteCode(), - Expiration: msg.GetInviteExpiration(), - Inviter: info.Sender.ToNonAD(), - }, - } - } - - htmlMessage := fmt.Sprintf(template, event.TextToHTML(msg.GetCaption()), msg.GetGroupName(), expiry) - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: format.HTMLToText(htmlMessage), - Format: event.FormatHTML, - FormattedBody: htmlMessage, - } - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: content, - Extra: extraAttrs, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertContactMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ContactMessage) *ConvertedMessage { - fileName := fmt.Sprintf("%s.vcf", msg.GetDisplayName()) - data := []byte(msg.GetVcard()) - mimeType := "text/vcard" - uploadMimeType, file := portal.encryptFileInPlace(data, mimeType) - - uploadResp, err := intent.UploadBytesWithName(ctx, data, uploadMimeType, fileName) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("displayname", msg.GetDisplayName()).Msg("Failed to upload vcard") - return nil - } - - content := &event.MessageEventContent{ - Body: fileName, - MsgType: event.MsgFile, - File: file, - Info: &event.FileInfo{ - MimeType: mimeType, - Size: len(msg.GetVcard()), - }, - } - if content.File != nil { - content.File.URL = uploadResp.ContentURI.CUString() - } else { - content.URL = uploadResp.ContentURI.CUString() - } - - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: content, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - } -} - -func (portal *Portal) convertContactsArrayMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ContactsArrayMessage) *ConvertedMessage { - name := msg.GetDisplayName() - if len(name) == 0 { - name = fmt.Sprintf("%d contacts", len(msg.GetContacts())) - } - contacts := make([]*event.MessageEventContent, 0, len(msg.GetContacts())) - for _, contact := range msg.GetContacts() { - converted := portal.convertContactMessage(ctx, intent, contact) - if converted != nil { - contacts = append(contacts, converted.Content) - } - } - return &ConvertedMessage{ - Intent: intent, - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Sent %s", name), - }, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - MultiEvent: contacts, - } -} - -func (portal *Portal) tryKickUser(ctx context.Context, userID id.UserID, intent *appservice.IntentAPI) error { - _, err := intent.KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: userID}) - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: userID}) - } - return err -} - -func (portal *Portal) removeUser(ctx context.Context, isSameUser bool, kicker *appservice.IntentAPI, target id.UserID, targetIntent *appservice.IntentAPI) { - if !isSameUser || targetIntent == nil { - err := portal.tryKickUser(ctx, target, kicker) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Stringer("target_mxid", target).Msg("Failed to kick user from portal") - if targetIntent != nil { - _, _ = targetIntent.LeaveRoom(ctx, portal.MXID) - } - } - } else { - _, err := targetIntent.LeaveRoom(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Stringer("target_mxid", target).Msg("Failed to leave portal as user") - _, _ = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: target}) - } - } - portal.CleanupIfEmpty(ctx) -} - -func (portal *Portal) HandleWhatsAppKick(ctx context.Context, source *User, senderJID types.JID, jids []types.JID) { - sender := portal.bridge.GetPuppetByJID(senderJID) - senderIntent := sender.IntentFor(portal) - for _, jid := range jids { - if jid.Server != types.DefaultUserServer { - // TODO handle lids - continue - } - //if source != nil && source.JID.User == jid.User { - // portal.log.Debugln("Ignoring self-kick by", source.MXID) - // continue - //} - puppet := portal.bridge.GetPuppetByJID(jid) - portal.removeUser(ctx, puppet.JID == sender.JID, senderIntent, puppet.MXID, puppet.DefaultIntent()) - - if !portal.IsBroadcastList() { - user := portal.bridge.GetUserByJID(jid) - if user != nil { - var customIntent *appservice.IntentAPI - if puppet.CustomMXID == user.MXID { - customIntent = puppet.CustomIntent() - } - portal.removeUser(ctx, puppet.JID == sender.JID, senderIntent, user.MXID, customIntent) - } - } - } -} - -func (portal *Portal) HandleWhatsAppInvite(ctx context.Context, source *User, senderJID *types.JID, jids []types.JID) (evtID id.EventID) { - intent := portal.MainIntent() - if senderJID != nil && !senderJID.IsEmpty() { - sender := portal.bridge.GetPuppetByJID(*senderJID) - intent = sender.IntentFor(portal) - } - for _, jid := range jids { - if jid.Server != types.DefaultUserServer { - // TODO handle lids - continue - } - puppet := portal.bridge.GetPuppetByJID(jid) - puppet.SyncContact(ctx, source, true, false, "handling whatsapp invite") - resp, err := intent.SendStateEvent(ctx, portal.MXID, event.StateMember, puppet.MXID.String(), &event.MemberEventContent{ - Membership: event.MembershipInvite, - Displayname: puppet.Displayname, - AvatarURL: puppet.AvatarURL.CUString(), - }) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err). - Stringer("target_mxid", puppet.MXID). - Stringer("inviter_mxid", intent.UserID). - Msg("Failed to invite user") - _ = portal.MainIntent().EnsureInvited(ctx, portal.MXID, puppet.MXID) - } else { - evtID = resp.EventID - } - err = puppet.DefaultIntent().EnsureJoined(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("target_mxid", puppet.MXID). - Msg("Failed to ensure user is joined to portal") - } - } - return -} - -func (portal *Portal) HandleWhatsAppDeleteChat(ctx context.Context, user *User) { - if portal.MXID == "" { - return - } - matrixUsers, err := portal.GetMatrixUsers(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get Matrix users to see if DeleteChat should be handled") - return - } - if len(matrixUsers) > 1 { - zerolog.Ctx(ctx).Debug().Msg("Portal contains more than one Matrix user, ignoring DeleteChat event") - return - } else if (len(matrixUsers) == 1 && matrixUsers[0] == user.MXID) || len(matrixUsers) < 1 { - zerolog.Ctx(ctx).Debug().Msg("User deleted chat and there are no other Matrix users, deleting portal...") - portal.Delete(ctx) - portal.Cleanup(ctx, false) - } -} - -const failedMediaField = "fi.mau.whatsapp.failed_media" - -type FailedMediaKeys struct { - Key []byte `json:"key"` - Length int `json:"length"` - Type whatsmeow.MediaType `json:"type"` - SHA256 []byte `json:"sha256"` - EncSHA256 []byte `json:"enc_sha256"` -} - -type FailedMediaMeta struct { - Type event.Type `json:"type"` - Content *event.MessageEventContent `json:"content"` - ExtraContent map[string]interface{} `json:"extra_content,omitempty"` - Media FailedMediaKeys `json:"whatsapp_media"` -} - -func (portal *Portal) makeMediaBridgeFailureMessage(info *types.MessageInfo, bridgeErr error, converted *ConvertedMessage, keys *FailedMediaKeys, userFriendlyError string) *ConvertedMessage { - if errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith410) { - portal.zlog.Debug().Err(bridgeErr).Str("message_id", info.ID).Msg("Failed to bridge media for message") - } else { - portal.zlog.Err(bridgeErr).Str("message_id", info.ID).Msg("Failed to bridge media for message") - } - if keys != nil { - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - meta := &FailedMediaMeta{ - Type: converted.Type, - Content: converted.Content, - ExtraContent: maps.Clone(converted.Extra), - Media: *keys, - } - converted.Extra[failedMediaField] = meta - portal.mediaErrorCache[info.ID] = meta - } - converted.Type = event.EventMessage - body := userFriendlyError - if body == "" { - body = fmt.Sprintf("Failed to bridge media: %v", bridgeErr) - } - converted.Content = &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: body, - } - return converted -} - -func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) { - if !portal.Encrypted { - return mimeType, nil - } - - file := &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - return "application/octet-stream", file -} - -type MediaMessage interface { - whatsmeow.DownloadableMessage - GetContextInfo() *waProto.ContextInfo - GetFileLength() uint64 - GetMimetype() string -} - -type MediaMessageWithThumbnail interface { - MediaMessage - GetJPEGThumbnail() []byte -} - -type MediaMessageWithCaption interface { - MediaMessage - GetCaption() string -} - -type MediaMessageWithDimensions interface { - MediaMessage - GetHeight() uint32 - GetWidth() uint32 -} - -type MediaMessageWithFileName interface { - MediaMessage - GetFileName() string -} - -type MediaMessageWithDuration interface { - MediaMessage - GetSeconds() uint32 -} - -const WhatsAppStickerSize = 190 - -func (portal *Portal) convertMediaMessageContent(ctx context.Context, intent *appservice.IntentAPI, msg MediaMessage) *ConvertedMessage { - content := &event.MessageEventContent{ - Info: &event.FileInfo{ - MimeType: msg.GetMimetype(), - Size: int(msg.GetFileLength()), - }, - } - extraContent := map[string]interface{}{} - - messageWithDimensions, ok := msg.(MediaMessageWithDimensions) - if ok { - content.Info.Width = int(messageWithDimensions.GetWidth()) - content.Info.Height = int(messageWithDimensions.GetHeight()) - } - - msgWithName, ok := msg.(MediaMessageWithFileName) - if ok && len(msgWithName.GetFileName()) > 0 { - content.Body = msgWithName.GetFileName() - } else { - mimeClass := strings.Split(msg.GetMimetype(), "/")[0] - switch mimeClass { - case "application": - content.Body = "file" - default: - content.Body = mimeClass - } - - content.Body += exmime.ExtensionFromMimetype(msg.GetMimetype()) - } - - msgWithDuration, ok := msg.(MediaMessageWithDuration) - if ok { - content.Info.Duration = int(msgWithDuration.GetSeconds()) * 1000 - } - - videoMessage, ok := msg.(*waProto.VideoMessage) - var isGIF bool - if ok && videoMessage.GetGifPlayback() { - isGIF = true - extraContent["info"] = map[string]interface{}{ - "fi.mau.loop": true, - "fi.mau.autoplay": true, - "fi.mau.hide_controls": true, - "fi.mau.no_audio": true, - "fi.mau.gif": true, - } - } - - messageWithThumbnail, ok := msg.(MediaMessageWithThumbnail) - if ok && messageWithThumbnail.GetJPEGThumbnail() != nil && (portal.bridge.Config.Bridge.WhatsappThumbnail || isGIF) { - thumbnailData := messageWithThumbnail.GetJPEGThumbnail() - thumbnailMime := http.DetectContentType(thumbnailData) - thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(thumbnailData)) - thumbnailSize := len(thumbnailData) - thumbnailUploadMime, thumbnailFile := portal.encryptFileInPlace(thumbnailData, thumbnailMime) - uploadedThumbnail, err := intent.UploadBytes(ctx, thumbnailData, thumbnailUploadMime) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to upload thumbnail") - } else if uploadedThumbnail != nil { - if thumbnailFile != nil { - thumbnailFile.URL = uploadedThumbnail.ContentURI.CUString() - content.Info.ThumbnailFile = thumbnailFile - } else { - content.Info.ThumbnailURL = uploadedThumbnail.ContentURI.CUString() - } - content.Info.ThumbnailInfo = &event.FileInfo{ - Size: thumbnailSize, - Width: thumbnailCfg.Width, - Height: thumbnailCfg.Height, - MimeType: thumbnailMime, - } - } - } - - eventType := event.EventMessage - switch msg.(type) { - case *waProto.ImageMessage: - content.MsgType = event.MsgImage - case *waProto.StickerMessage: - eventType = event.EventSticker - if content.Info.Width > content.Info.Height { - content.Info.Height /= content.Info.Width / WhatsAppStickerSize - content.Info.Width = WhatsAppStickerSize - } else if content.Info.Width < content.Info.Height { - content.Info.Width /= content.Info.Height / WhatsAppStickerSize - content.Info.Height = WhatsAppStickerSize - } else { - content.Info.Width = WhatsAppStickerSize - content.Info.Height = WhatsAppStickerSize - } - case *waProto.VideoMessage: - content.MsgType = event.MsgVideo - case *waProto.AudioMessage: - content.MsgType = event.MsgAudio - case *waProto.DocumentMessage: - content.MsgType = event.MsgFile - default: - zerolog.Ctx(ctx).Warn().Type("content_struct", msg).Msg("Unexpected media type in convertMediaMessageContent") - content.MsgType = event.MsgFile - } - - audioMessage, ok := msg.(*waProto.AudioMessage) - if ok { - var waveform []int - if audioMessage.Waveform != nil { - waveform = make([]int, len(audioMessage.Waveform)) - maxWave := 0 - for i, part := range audioMessage.Waveform { - waveform[i] = int(part) - if waveform[i] > maxWave { - maxWave = waveform[i] - } - } - multiplier := 0 - if maxWave > 0 { - multiplier = 1024 / maxWave - } - if multiplier > 32 { - multiplier = 32 - } - for i := range waveform { - waveform[i] *= multiplier - } - } - extraContent["org.matrix.msc1767.audio"] = map[string]interface{}{ - "duration": int(audioMessage.GetSeconds()) * 1000, - "waveform": waveform, - } - if audioMessage.GetPTT() || audioMessage.GetMimetype() == "audio/ogg; codecs/opus" { - extraContent["org.matrix.msc3245.voice"] = map[string]interface{}{} - } - } - - messageWithCaption, ok := msg.(MediaMessageWithCaption) - var captionContent *event.MessageEventContent - if ok && len(messageWithCaption.GetCaption()) > 0 { - captionContent = &event.MessageEventContent{ - Body: messageWithCaption.GetCaption(), - MsgType: event.MsgNotice, - } - - portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, captionContent, msg.GetContextInfo().GetMentionedJID(), false, false) - } - - return &ConvertedMessage{ - Intent: intent, - Type: eventType, - Content: content, - Caption: captionContent, - ReplyTo: GetReply(msg.GetContextInfo()), - ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second, - Extra: extraContent, - } -} - -func (portal *Portal) uploadMedia(ctx context.Context, intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error { - uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType) - - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: uploadMimeType, - } - var mxc id.ContentURI - if portal.bridge.Config.Homeserver.AsyncMedia { - uploaded, err := intent.UploadAsync(ctx, req) - if err != nil { - return err - } - mxc = uploaded.ContentURI - } else { - uploaded, err := intent.UploadMedia(ctx, req) - if err != nil { - return err - } - mxc = uploaded.ContentURI - } - - if file != nil { - file.URL = mxc.CUString() - content.File = file - } else { - content.URL = mxc.CUString() - } - - content.Info.Size = len(data) - if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) - content.Info.Width, content.Info.Height = cfg.Width, cfg.Height - } - - // This is a hack for bad clients like Element iOS that require a thumbnail (https://github.com/vector-im/element-ios/issues/4004) - if strings.HasPrefix(content.Info.MimeType, "image/") && content.Info.ThumbnailInfo == nil { - infoCopy := *content.Info - content.Info.ThumbnailInfo = &infoCopy - if content.File != nil { - content.Info.ThumbnailFile = file - } else { - content.Info.ThumbnailURL = content.URL - } - } - return nil -} - -func (portal *Portal) convertMediaMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg MediaMessage, typeName string, isBackfill bool) *ConvertedMessage { - converted := portal.convertMediaMessageContent(ctx, intent, msg) - if msg.GetFileLength() > uint64(portal.bridge.MediaConfig.UploadSize) { - return portal.makeMediaBridgeFailureMessage(info, errors.New("file is too large"), converted, nil, fmt.Sprintf("Large %s not bridged - please use WhatsApp app to view", typeName)) - } - data, err := source.Client.Download(msg) - if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) { - converted.Error = database.MsgErrMediaNotFound - converted.MediaKey = msg.GetMediaKey() - - errorText := fmt.Sprintf("Old %s.", typeName) - if portal.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia && isBackfill { - errorText += " Media will be automatically requested from your phone later." - } else { - errorText += " React with the \u267b (recycle) emoji to request this media from your phone." - } - - return portal.makeMediaBridgeFailureMessage(info, err, converted, &FailedMediaKeys{ - Key: msg.GetMediaKey(), - Length: int(msg.GetFileLength()), - Type: whatsmeow.GetMediaType(msg), - SHA256: msg.GetFileSHA256(), - EncSHA256: msg.GetFileEncSHA256(), - }, errorText) - } else if errors.Is(err, whatsmeow.ErrNoURLPresent) { - zerolog.Ctx(ctx).Debug().Msg("No URL present error for media message, ignoring...") - return nil - } else if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") - } else if err != nil { - return portal.makeMediaBridgeFailureMessage(info, err, converted, nil, "") - } - - err = portal.uploadMedia(ctx, intent, data, converted.Content) - if err != nil { - if errors.Is(err, mautrix.MTooLarge) { - return portal.makeMediaBridgeFailureMessage(info, errors.New("homeserver rejected too large file"), converted, nil, "") - } else if httpErr := (mautrix.HTTPError{}); errors.As(err, &httpErr) && httpErr.IsStatus(413) { - return portal.makeMediaBridgeFailureMessage(info, errors.New("proxy rejected too large file"), converted, nil, "") - } else { - return portal.makeMediaBridgeFailureMessage(info, fmt.Errorf("failed to upload media: %w", err), converted, nil, "") - } - } - return converted -} - -func (portal *Portal) fetchMediaRetryEvent(ctx context.Context, msg *database.Message) (*FailedMediaMeta, error) { - errorMeta, ok := portal.mediaErrorCache[msg.JID] - if ok { - return errorMeta, nil - } - evt, err := portal.MainIntent().GetEvent(ctx, portal.MXID, msg.MXID) - if err != nil { - return nil, fmt.Errorf("failed to fetch event %s: %w", msg.MXID, err) - } - if evt.Type == event.EventEncrypted { - err = evt.Content.ParseRaw(evt.Type) - if err != nil { - return nil, fmt.Errorf("failed to parse encrypted content in %s: %w", msg.MXID, err) - } - evt, err = portal.bridge.Crypto.Decrypt(ctx, evt) - if err != nil { - return nil, fmt.Errorf("failed to decrypt event %s: %w", msg.MXID, err) - } - } - errorMetaResult := gjson.GetBytes(evt.Content.VeryRaw, strings.ReplaceAll(failedMediaField, ".", "\\.")) - if !errorMetaResult.Exists() || !errorMetaResult.IsObject() { - return nil, fmt.Errorf("didn't find failed media metadata in %s", msg.MXID) - } - var errorMetaBytes []byte - if errorMetaResult.Index > 0 { - errorMetaBytes = evt.Content.VeryRaw[errorMetaResult.Index : errorMetaResult.Index+len(errorMetaResult.Raw)] - } else { - errorMetaBytes = []byte(errorMetaResult.Raw) - } - err = json.Unmarshal(errorMetaBytes, &errorMeta) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal failed media metadata in %s: %w", msg.MXID, err) - } - return errorMeta, nil -} - -func (portal *Portal) sendMediaRetryFailureEdit(ctx context.Context, intent *appservice.IntentAPI, msg *database.Message, err error) { - content := event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Failed to bridge media after re-requesting it from your phone: %v", err), - } - contentCopy := content - content.NewContent = &contentCopy - content.RelatesTo = &event.RelatesTo{ - EventID: msg.MXID, - Type: event.RelReplace, - } - resp, sendErr := portal.sendMessage(ctx, intent, event.EventMessage, &content, nil, time.Now().UnixMilli()) - if sendErr != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to edit message after media retry failure") - } else { - zerolog.Ctx(ctx).Debug().Stringer("edit_mxid", resp.EventID). - Msg("Successfully edited message after media retry failure") - } -} - -func (portal *Portal) handleMediaRetry(retry *events.MediaRetry, source *User) { - log := portal.zlog.With(). - Str("action", "handle media retry"). - Str("retry_message_id", retry.MessageID). - Logger() - ctx := log.WithContext(context.TODO()) - err := source.mediaRetryLock.Acquire(ctx, 1) - if err != nil { - log.Err(err).Msg("Failed to acquire media retry semaphore") - return - } - defer source.mediaRetryLock.Release(1) - - msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, retry.MessageID) - if msg == nil { - log.Warn().Msg("Dropping media retry notification for unknown message") - return - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("retry_message_mxid", msg.MXID) - }) - if msg.Error != database.MsgErrMediaNotFound { - log.Warn().Msg("Dropping media retry notification for non-errored message") - return - } - - meta, err := portal.fetchMediaRetryEvent(ctx, msg) - if err != nil { - log.Warn().Err(err).Msg("Can't handle media retry notification for message") - return - } - - var puppet *Puppet - if retry.FromMe { - puppet = portal.bridge.GetPuppetByJID(source.JID) - } else if retry.ChatID.Server == types.DefaultUserServer { - puppet = portal.bridge.GetPuppetByJID(retry.ChatID) - } else { - puppet = portal.bridge.GetPuppetByJID(retry.SenderID) - } - if puppet == nil { - // TODO handle lids? - return - } - intent := puppet.IntentFor(portal) - - retryData, err := whatsmeow.DecryptMediaRetryNotification(retry, meta.Media.Key) - if err != nil { - log.Warn().Err(err).Msg("Failed to decrypt media retry notification") - portal.sendMediaRetryFailureEdit(ctx, intent, msg, err) - return - } else if retryData.GetResult() != waProto.MediaRetryNotification_SUCCESS { - errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())] - if retryData.GetDirectPath() == "" { - log.Warn().Str("error_name", errorName).Msg("Got error response in media retry notification") - log.Debug().Any("error_content", retryData).Msg("Full error response content") - if retryData.GetResult() == waProto.MediaRetryNotification_NOT_FOUND { - portal.sendMediaRetryFailureEdit(ctx, intent, msg, whatsmeow.ErrMediaNotAvailableOnPhone) - } else { - portal.sendMediaRetryFailureEdit(ctx, intent, msg, fmt.Errorf("phone sent error response: %s", errorName)) - } - return - } else { - log.Debug().Msg("Got error response in media retry notification, but response also contains a new download URL - trying to download") - } - } - - data, err := source.Client.DownloadMediaWithPath(retryData.GetDirectPath(), meta.Media.EncSHA256, meta.Media.SHA256, meta.Media.Key, meta.Media.Length, meta.Media.Type, "") - if err != nil { - log.Warn().Err(err).Msg("Failed to download media after retry notification") - portal.sendMediaRetryFailureEdit(ctx, intent, msg, err) - return - } - err = portal.uploadMedia(ctx, intent, data, meta.Content) - if err != nil { - log.Err(err).Msg("Failed to re-upload media after retry notification") - portal.sendMediaRetryFailureEdit(ctx, intent, msg, fmt.Errorf("re-uploading media failed: %v", err)) - return - } - replaceContent := &event.MessageEventContent{ - MsgType: meta.Content.MsgType, - Body: "* " + meta.Content.Body, - NewContent: meta.Content, - RelatesTo: &event.RelatesTo{ - EventID: msg.MXID, - Type: event.RelReplace, - }, - } - // Move the extra content into m.new_content too - meta.ExtraContent = map[string]interface{}{ - "m.new_content": maps.Clone(meta.ExtraContent), - } - resp, err := portal.sendMessage(ctx, intent, meta.Type, replaceContent, meta.ExtraContent, time.Now().UnixMilli()) - if err != nil { - log.Err(err).Msg("Failed to edit message after reuploading media from retry notification") - return - } - log.Debug().Stringer("edit_mxid", resp.EventID).Msg("Successfully edited message after retry notification") - err = msg.UpdateMXID(ctx, resp.EventID, database.MsgNormal, database.MsgNoError) - if err != nil { - log.Err(err).Msg("Failed to save message to database after editing with retry notification") - } -} - -func (portal *Portal) requestMediaRetry(ctx context.Context, user *User, eventID id.EventID, mediaKey []byte) (bool, error) { - log := zerolog.Ctx(ctx).With().Stringer("target_event_id", eventID).Logger() - msg, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID) - if err != nil { - log.Err(err).Msg("Failed to get media retry target from database") - return false, fmt.Errorf("failed to get media retry target") - } else if msg == nil { - log.Debug().Msg("Can't send media retry request for unknown message") - return false, fmt.Errorf("unknown message") - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("target_message_id", msg.JID) - }) - if msg.Error != database.MsgErrMediaNotFound { - log.Debug().Msg("Dropping media retry request for non-errored message") - return false, fmt.Errorf("message is not errored") - } - - // If the media key is not provided, grab it from the event in Matrix - if mediaKey == nil { - evt, err := portal.fetchMediaRetryEvent(ctx, msg) - if err != nil { - log.Warn().Err(err).Msg("Dropping media retry request as media key couldn't be fetched") - return true, nil - } - mediaKey = evt.Media.Key - } - - err = user.Client.SendMediaRetryReceipt(&types.MessageInfo{ - ID: msg.JID, - MessageSource: types.MessageSource{ - IsFromMe: msg.Sender.User == user.JID.User, - IsGroup: !portal.IsPrivateChat(), - Sender: msg.Sender, - Chat: portal.Key.JID, - }, - }, mediaKey) - if err != nil { - log.Err(err).Msg("Failed to send media retry request") - } else { - log.Debug().Msg("Sent media retry request") - } - return true, err -} - -const thumbnailMaxSize = 72 -const thumbnailMinSize = 24 - -func createThumbnailAndGetSize(source []byte, pngThumbnail bool) ([]byte, int, int, error) { - src, _, err := image.Decode(bytes.NewReader(source)) - if err != nil { - return nil, 0, 0, fmt.Errorf("failed to decode thumbnail: %w", err) - } - imageBounds := src.Bounds() - width, height := imageBounds.Max.X, imageBounds.Max.Y - var img image.Image - if width <= thumbnailMaxSize && height <= thumbnailMaxSize { - // No need to resize - img = src - } else { - if width == height { - width = thumbnailMaxSize - height = thumbnailMaxSize - } else if width < height { - width /= height / thumbnailMaxSize - height = thumbnailMaxSize - } else { - height /= width / thumbnailMaxSize - width = thumbnailMaxSize - } - if width < thumbnailMinSize { - width = thumbnailMinSize - } - if height < thumbnailMinSize { - height = thumbnailMinSize - } - dst := image.NewRGBA(image.Rect(0, 0, width, height)) - draw.NearestNeighbor.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) - img = dst - } - - var buf bytes.Buffer - if pngThumbnail { - err = png.Encode(&buf, img) - } else { - err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) - } - if err != nil { - return nil, width, height, fmt.Errorf("failed to re-encode thumbnail: %w", err) - } - return buf.Bytes(), width, height, nil -} - -func createThumbnail(source []byte, png bool) ([]byte, error) { - data, _, _, err := createThumbnailAndGetSize(source, png) - return data, err -} - -func (portal *Portal) downloadThumbnail(ctx context.Context, original []byte, thumbnailURL id.ContentURIString, eventID id.EventID, png bool) ([]byte, error) { - if len(thumbnailURL) == 0 { - // just fall back to making thumbnail of original - } else if mxc, err := thumbnailURL.Parse(); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Malformed thumbnail URL in event, falling back to generating thumbnail from source") - } else if thumbnail, err := portal.MainIntent().DownloadBytes(ctx, mxc); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to download thumbnail in event, falling back to generating thumbnail from source") - } else { - return createThumbnail(thumbnail, png) - } - return createThumbnail(original, png) -} - -func (portal *Portal) convertWebPtoPNG(webpImage []byte) ([]byte, error) { - webpDecoded, err := webp.Decode(bytes.NewReader(webpImage)) - if err != nil { - return nil, fmt.Errorf("failed to decode webp image: %w", err) - } - - var pngBuffer bytes.Buffer - if err = png.Encode(&pngBuffer, webpDecoded); err != nil { - return nil, fmt.Errorf("failed to encode png image: %w", err) - } - - return pngBuffer.Bytes(), nil -} - -type PaddedImage struct { - image.Image - Size int - OffsetX int - OffsetY int -} - -func (img *PaddedImage) Bounds() image.Rectangle { - return image.Rect(0, 0, img.Size, img.Size) -} - -func (img *PaddedImage) At(x, y int) color.Color { - return img.Image.At(x+img.OffsetX, y+img.OffsetY) -} - -func (portal *Portal) convertToWebP(img []byte) ([]byte, error) { - decodedImg, _, err := image.Decode(bytes.NewReader(img)) - if err != nil { - return img, fmt.Errorf("failed to decode image: %w", err) - } - - bounds := decodedImg.Bounds() - width, height := bounds.Dx(), bounds.Dy() - if width != height { - paddedImg := &PaddedImage{ - Image: decodedImg, - OffsetX: bounds.Min.Y, - OffsetY: bounds.Min.X, - } - if width > height { - paddedImg.Size = width - paddedImg.OffsetY -= (paddedImg.Size - height) / 2 - } else { - paddedImg.Size = height - paddedImg.OffsetX -= (paddedImg.Size - width) / 2 - } - decodedImg = paddedImg - } - - var webpBuffer bytes.Buffer - if err = cwebp.Encode(&webpBuffer, decodedImg, nil); err != nil { - return img, fmt.Errorf("failed to encode webp image: %w", err) - } - - return webpBuffer.Bytes(), nil -} - -func (portal *Portal) preprocessMatrixMedia(ctx context.Context, sender *User, relaybotFormatted bool, content *event.MessageEventContent, eventID id.EventID, mediaType whatsmeow.MediaType) (*MediaUpload, error) { - fileName := content.Body - var caption string - var mentionedJIDs []string - var hasHTMLCaption bool - isSticker := string(content.MsgType) == event.EventSticker.Type - if content.FileName != "" && content.Body != content.FileName { - fileName = content.FileName - caption = content.Body - hasHTMLCaption = content.Format == event.FormatHTML - } - if relaybotFormatted || hasHTMLCaption { - caption, mentionedJIDs = portal.bridge.Formatter.ParseMatrix(content.FormattedBody, content.Mentions) - } - - var file *event.EncryptedFileInfo - rawMXC := content.URL - if content.File != nil { - file = content.File - rawMXC = file.URL - } - mxc, err := rawMXC.Parse() - if err != nil { - return nil, err - } - data, err := portal.MainIntent().DownloadBytes(ctx, mxc) - if err != nil { - return nil, exerrors.NewDualError(errMediaDownloadFailed, err) - } - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return nil, exerrors.NewDualError(errMediaDecryptFailed, err) - } - } - mimeType := content.GetInfo().MimeType - if mimeType == "" { - content.Info.MimeType = "application/octet-stream" - } - var convertErr error - // Allowed mime types from https://developers.facebook.com/docs/whatsapp/on-premises/reference/media - switch { - case isSticker: - if mimeType != "image/webp" || content.Info.Width != content.Info.Height { - data, convertErr = portal.convertToWebP(data) - content.Info.MimeType = "image/webp" - } - case mediaType == whatsmeow.MediaVideo: - switch mimeType { - case "video/mp4", "video/3gpp": - // Allowed - case "image/gif": - data, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "gif"}, []string{ - "-pix_fmt", "yuv420p", "-c:v", "libx264", "-movflags", "+faststart", - "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'", - }, mimeType) - content.Info.MimeType = "video/mp4" - case "video/webm": - data, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "webm"}, []string{ - "-pix_fmt", "yuv420p", "-c:v", "libx264", - }, mimeType) - content.Info.MimeType = "video/mp4" - default: - return nil, fmt.Errorf("%w %q in video message", errMediaUnsupportedType, mimeType) - } - case mediaType == whatsmeow.MediaImage: - switch mimeType { - case "image/jpeg", "image/png": - // Allowed - case "image/webp": - data, convertErr = portal.convertWebPtoPNG(data) - content.Info.MimeType = "image/png" - default: - return nil, fmt.Errorf("%w %q in image message", errMediaUnsupportedType, mimeType) - } - case mediaType == whatsmeow.MediaAudio: - switch mimeType { - case "audio/aac", "audio/mp4", "audio/amr", "audio/mpeg", "audio/ogg; codecs=opus": - // Allowed - case "audio/ogg": - // Hopefully it's opus already - content.Info.MimeType = "audio/ogg; codecs=opus" - default: - return nil, fmt.Errorf("%w %q in audio message", errMediaUnsupportedType, mimeType) - } - case mediaType == whatsmeow.MediaDocument: - // Everything is allowed - } - if convertErr != nil { - if content.Info.MimeType != mimeType || data == nil { - return nil, exerrors.NewDualError(fmt.Errorf("%w (%s to %s)", errMediaConvertFailed, mimeType, content.Info.MimeType), convertErr) - } else { - // If the mime type didn't change and the errored conversion function returned the original data, just log a warning and continue - zerolog.Ctx(ctx).Warn().Err(convertErr).Str("source_mime", mimeType).Msg("Failed to re-encode media, continuing with original file") - } - } - var uploadResp whatsmeow.UploadResponse - if portal.Key.JID.Server == types.NewsletterServer { - uploadResp, err = sender.Client.UploadNewsletter(ctx, data, mediaType) - } else { - uploadResp, err = sender.Client.Upload(ctx, data, mediaType) - } - if err != nil { - return nil, exerrors.NewDualError(errMediaWhatsAppUploadFailed, err) - } - - // Audio doesn't have thumbnails - var thumbnail []byte - if mediaType != whatsmeow.MediaAudio { - thumbnail, err = portal.downloadThumbnail(ctx, data, content.GetInfo().ThumbnailURL, eventID, isSticker) - // Ignore format errors for non-image files, we don't care about those thumbnails - if err != nil && (!errors.Is(err, image.ErrFormat) || mediaType == whatsmeow.MediaImage) { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate thumbnail for image message") - } - } - - return &MediaUpload{ - UploadResponse: uploadResp, - FileName: fileName, - Caption: caption, - MentionedJIDs: mentionedJIDs, - Thumbnail: thumbnail, - FileLength: len(data), - }, nil -} - -type MediaUpload struct { - whatsmeow.UploadResponse - Caption string - FileName string - MentionedJIDs []string - Thumbnail []byte - FileLength int -} - -func (portal *Portal) addRelaybotFormat(ctx context.Context, userID id.UserID, content *event.MessageEventContent) bool { - member := portal.MainIntent().Member(ctx, portal.MXID, userID) - if member == nil { - member = &event.MemberEventContent{} - } - content.EnsureHasHTML() - data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to apply relaybot format") - } - content.FormattedBody = data - return true -} - -func addCodecToMime(mimeType, codec string) string { - mediaType, params, err := mime.ParseMediaType(mimeType) - if err != nil { - return mimeType - } - if _, ok := params["codecs"]; !ok { - params["codecs"] = codec - } - return mime.FormatMediaType(mediaType, params) -} - -func parseGeoURI(uri string) (lat, long float64, err error) { - if !strings.HasPrefix(uri, "geo:") { - err = fmt.Errorf("uri doesn't have geo: prefix") - return - } - // Remove geo: prefix and anything after ; - coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0] - - if splitCoordinates := strings.Split(coordinates, ","); len(splitCoordinates) != 2 { - err = fmt.Errorf("didn't find exactly two numbers separated by a comma") - } else if lat, err = strconv.ParseFloat(splitCoordinates[0], 64); err != nil { - err = fmt.Errorf("latitude is not a number: %w", err) - } else if long, err = strconv.ParseFloat(splitCoordinates[1], 64); err != nil { - err = fmt.Errorf("longitude is not a number: %w", err) - } - return -} - -func getUnstableWaveform(content map[string]interface{}) []byte { - audioInfo, ok := content["org.matrix.msc1767.audio"].(map[string]interface{}) - if !ok { - return nil - } - waveform, ok := audioInfo["waveform"].([]interface{}) - if !ok { - return nil - } - output := make([]byte, len(waveform)) - var val float64 - for i, part := range waveform { - val, ok = part.(float64) - if ok { - output[i] = byte(val / 4) - } - } - return output -} - -var ( - TypeMSC3381PollStart = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.start"} - TypeMSC3381PollResponse = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.response"} - TypeMSC3381V2PollResponse = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.v2.poll.response"} -) - -type PollResponseContent struct { - RelatesTo event.RelatesTo `json:"m.relates_to"` - V1Response struct { - Answers []string `json:"answers"` - } `json:"org.matrix.msc3381.poll.response"` - V2Selections []string `json:"org.matrix.msc3381.v2.selections"` -} - -func (content *PollResponseContent) GetRelatesTo() *event.RelatesTo { - return &content.RelatesTo -} - -func (content *PollResponseContent) OptionalGetRelatesTo() *event.RelatesTo { - if content.RelatesTo.Type == "" { - return nil - } - return &content.RelatesTo -} - -func (content *PollResponseContent) SetRelatesTo(rel *event.RelatesTo) { - content.RelatesTo = *rel -} - -type MSC1767Message struct { - Text string `json:"org.matrix.msc1767.text,omitempty"` - HTML string `json:"org.matrix.msc1767.html,omitempty"` - Message []struct { - MimeType string `json:"mimetype"` - Body string `json:"body"` - } `json:"org.matrix.msc1767.message,omitempty"` -} - -func (portal *Portal) msc1767ToWhatsApp(msg MSC1767Message, mentions bool) (string, []string) { - for _, part := range msg.Message { - if part.MimeType == "text/html" && msg.HTML == "" { - msg.HTML = part.Body - } else if part.MimeType == "text/plain" && msg.Text == "" { - msg.Text = part.Body - } - } - if msg.HTML != "" { - if mentions { - return portal.bridge.Formatter.ParseMatrix(msg.HTML, nil) - } else { - return portal.bridge.Formatter.ParseMatrixWithoutMentions(msg.HTML), nil - } - } - return msg.Text, nil -} - -type PollStartContent struct { - RelatesTo *event.RelatesTo `json:"m.relates_to"` - PollStart struct { - Kind string `json:"kind"` - MaxSelections int `json:"max_selections"` - Question MSC1767Message `json:"question"` - Answers []struct { - ID string `json:"id"` - MSC1767Message - } `json:"answers"` - } `json:"org.matrix.msc3381.poll.start"` -} - -func (content *PollStartContent) GetRelatesTo() *event.RelatesTo { - if content.RelatesTo == nil { - content.RelatesTo = &event.RelatesTo{} - } - return content.RelatesTo -} - -func (content *PollStartContent) OptionalGetRelatesTo() *event.RelatesTo { - return content.RelatesTo -} - -func (content *PollStartContent) SetRelatesTo(rel *event.RelatesTo) { - content.RelatesTo = rel -} - -func init() { - event.TypeMap[TypeMSC3381PollResponse] = reflect.TypeOf(PollResponseContent{}) - event.TypeMap[TypeMSC3381V2PollResponse] = reflect.TypeOf(PollResponseContent{}) - event.TypeMap[TypeMSC3381PollStart] = reflect.TypeOf(PollStartContent{}) -} - -func (portal *Portal) convertMatrixPollVote(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) { - content, ok := evt.Content.Parsed.(*PollResponseContent) - if !ok { - return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed) - } - var answers []string - if content.V1Response.Answers != nil { - answers = content.V1Response.Answers - } else if content.V2Selections != nil { - answers = content.V2Selections - } - log := zerolog.Ctx(ctx) - pollMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, content.RelatesTo.EventID) - if err != nil { - log.Err(err).Msg("Failed to get poll message from database") - return nil, sender, nil, fmt.Errorf("failed to get poll message") - } else if pollMsg == nil { - return nil, sender, nil, errTargetNotFound - } - pollMsgInfo := &types.MessageInfo{ - MessageSource: types.MessageSource{ - Chat: portal.Key.JID, - Sender: pollMsg.Sender, - IsFromMe: pollMsg.Sender.User == sender.JID.User, - IsGroup: portal.IsGroupChat(), - }, - ID: pollMsg.JID, - Type: "poll", - } - optionHashes := make([][]byte, 0, len(answers)) - if pollMsg.Type == database.MsgMatrixPoll { - mappedAnswers, err := pollMsg.GetPollOptionHashes(ctx, answers) - if err != nil { - log.Err(err).Msg("Failed to get poll option hashes from database") - return nil, sender, nil, fmt.Errorf("failed to get poll option hashes") - } - for _, selection := range answers { - hash, ok := mappedAnswers[selection] - if ok { - optionHashes = append(optionHashes, hash[:]) - } else { - log.Warn().Str("option", selection).Msg("Didn't find hash for selected option") - } - } - } else { - for _, selection := range answers { - hash, _ := hex.DecodeString(selection) - if hash != nil && len(hash) == 32 { - optionHashes = append(optionHashes, hash) - } - } - } - pollUpdate, err := sender.Client.EncryptPollVote(pollMsgInfo, &waProto.PollVoteMessage{ - SelectedOptions: optionHashes, - }) - return &waProto.Message{PollUpdateMessage: pollUpdate}, sender, nil, err -} - -func (portal *Portal) convertMatrixPollStart(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) { - content, ok := evt.Content.Parsed.(*PollStartContent) - if !ok { - return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed) - } - maxAnswers := content.PollStart.MaxSelections - if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 { - maxAnswers = 0 - } - ctxInfo := portal.generateContextInfo(ctx, content.RelatesTo) - var question string - question, ctxInfo.MentionedJID = portal.msc1767ToWhatsApp(content.PollStart.Question, true) - if len(question) == 0 { - return nil, sender, nil, errPollMissingQuestion - } - options := make([]*waProto.PollCreationMessage_Option, len(content.PollStart.Answers)) - optionMap := make(map[[32]byte]string, len(options)) - for i, opt := range content.PollStart.Answers { - body, _ := portal.msc1767ToWhatsApp(opt.MSC1767Message, false) - hash := sha256.Sum256([]byte(body)) - if _, alreadyExists := optionMap[hash]; alreadyExists { - zerolog.Ctx(ctx).Warn().Str("option", body).Msg("Poll has duplicate options, rejecting") - return nil, sender, nil, errPollDuplicateOption - } - optionMap[hash] = opt.ID - options[i] = &waProto.PollCreationMessage_Option{ - OptionName: proto.String(body), - } - } - secret := make([]byte, 32) - _, err := rand.Read(secret) - return &waProto.Message{ - PollCreationMessage: &waProto.PollCreationMessage{ - Name: proto.String(question), - Options: options, - SelectableOptionsCount: proto.Uint32(uint32(maxAnswers)), - ContextInfo: ctxInfo, - }, - MessageContextInfo: &waProto.MessageContextInfo{ - MessageSecret: secret, - }, - }, sender, &extraConvertMeta{PollOptions: optionMap}, err -} - -func (portal *Portal) generateContextInfo(ctx context.Context, relatesTo *event.RelatesTo) *waProto.ContextInfo { - var ctxInfo waProto.ContextInfo - replyToID := relatesTo.GetReplyTo() - if len(replyToID) > 0 { - replyToMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, replyToID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("reply_to_mxid", replyToID). - Msg("Failed to get reply target from database") - } - if replyToMsg != nil && !replyToMsg.IsFakeJID() && (replyToMsg.Type == database.MsgNormal || replyToMsg.Type == database.MsgMatrixPoll || replyToMsg.Type == database.MsgBeeperGallery) { - ctxInfo.StanzaID = &replyToMsg.JID - ctxInfo.Participant = proto.String(replyToMsg.Sender.ToNonAD().String()) - // Using blank content here seems to work fine on all official WhatsApp apps. - // - // We could probably invent a slightly more accurate version of the quoted message - // by fetching the Matrix event and converting it to the WhatsApp format, but that's - // a lot of work and this works fine. - ctxInfo.QuotedMessage = &waProto.Message{Conversation: proto.String("")} - } - } - if portal.ExpirationTime != 0 { - ctxInfo.Expiration = proto.Uint32(portal.ExpirationTime) - } - return &ctxInfo -} - -type extraConvertMeta struct { - PollOptions map[[32]byte]string - EditRootMsg *database.Message - - GalleryExtraParts []*waProto.Message - - MediaHandle string -} - -func getEditError(rootMsg *database.Message, editer *User) error { - switch { - case rootMsg == nil: - return errEditUnknownTarget - case rootMsg.Type != database.MsgNormal || rootMsg.IsFakeJID(): - return errEditUnknownTargetType - case rootMsg.Sender.User != editer.JID.User: - return errEditDifferentSender - case time.Since(rootMsg.Timestamp) > whatsmeow.EditWindow: - return errEditTooOld - default: - return nil - } -} - -func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) { - if evt.Type == TypeMSC3381PollResponse || evt.Type == TypeMSC3381V2PollResponse { - return portal.convertMatrixPollVote(ctx, sender, evt) - } else if evt.Type == TypeMSC3381PollStart { - return portal.convertMatrixPollStart(ctx, sender, evt) - } - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed) - } - extraMeta := &extraConvertMeta{} - realSenderMXID := sender.MXID - isRelay := false - if !sender.IsLoggedIn() || (portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User) { - if !portal.HasRelaybot() { - return nil, sender, extraMeta, errUserNotLoggedIn - } - sender = portal.GetRelayUser() - if !sender.IsLoggedIn() { - return nil, sender, extraMeta, errRelaybotNotLoggedIn - } - isRelay = true - } - log := zerolog.Ctx(ctx) - var editRootMsg *database.Message - if editEventID := content.RelatesTo.GetReplaceID(); editEventID != "" { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("edit_target_mxid", editEventID) - }) - var err error - editRootMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, editEventID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get edit target message from database") - return nil, sender, extraMeta, errEditUnknownTarget - } else if editErr := getEditError(editRootMsg, sender); editErr != nil { - return nil, sender, extraMeta, editErr - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("edit_target_id", editRootMsg.JID) - }) - extraMeta.EditRootMsg = editRootMsg - if content.NewContent != nil { - content = content.NewContent - } - } - - msg := &waProto.Message{} - ctxInfo := portal.generateContextInfo(ctx, content.RelatesTo) - relaybotFormatted := isRelay && portal.addRelaybotFormat(ctx, realSenderMXID, content) - if evt.Type == event.EventSticker { - if relaybotFormatted { - // Stickers can't have captions, so force relaybot stickers to be images - content.MsgType = event.MsgImage - } else { - content.MsgType = event.MessageType(event.EventSticker.Type) - } - } - if content.MsgType == event.MsgImage && content.GetInfo().MimeType == "image/gif" { - content.MsgType = event.MsgVideo - } - if content.MsgType == event.MsgAudio && content.FileName != "" && content.Body != content.FileName { - // Send audio messages with captions as files since WhatsApp doesn't support captions on audio messages - content.MsgType = event.MsgFile - } - - switch content.MsgType { - case event.MsgText, event.MsgEmote, event.MsgNotice: - text := content.Body - if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices { - return nil, sender, extraMeta, errMNoticeDisabled - } - if content.Format == event.FormatHTML { - text, ctxInfo.MentionedJID = portal.bridge.Formatter.ParseMatrix(content.FormattedBody, content.Mentions) - } - if content.MsgType == event.MsgEmote && !relaybotFormatted { - text = "/me " + text - } - msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ - Text: &text, - ContextInfo: ctxInfo, - } - hasPreview := portal.convertURLPreviewToWhatsApp(ctx, sender, content, msg.ExtendedTextMessage) - if ctx.Err() != nil { - return nil, sender, extraMeta, ctx.Err() - } - if ctxInfo.StanzaID == nil && ctxInfo.MentionedJID == nil && ctxInfo.Expiration == nil && !hasPreview { - // No need for extended message - msg.ExtendedTextMessage = nil - msg.Conversation = &text - } - case event.MsgImage: - media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage) - if media == nil { - return nil, sender, extraMeta, err - } - extraMeta.MediaHandle = media.Handle - ctxInfo.MentionedJID = media.MentionedJIDs - msg.ImageMessage = &waProto.ImageMessage{ - ContextInfo: ctxInfo, - Caption: &media.Caption, - JPEGThumbnail: media.Thumbnail, - URL: &media.URL, - DirectPath: &media.DirectPath, - MediaKey: media.MediaKey, - Mimetype: &content.GetInfo().MimeType, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - case event.MsgBeeperGallery: - if isRelay { - return nil, sender, extraMeta, errGalleryRelay - } else if content.BeeperGalleryCaption != "" { - return nil, sender, extraMeta, errGalleryCaption - } else if portal.Key.JID.Server == types.NewsletterServer { - // We don't handle the media handles properly for multiple messages - return nil, sender, extraMeta, fmt.Errorf("can't send gallery to newsletter") - } - for i, part := range content.BeeperGalleryImages { - // TODO support videos - media, err := portal.preprocessMatrixMedia(ctx, sender, false, part, evt.ID, whatsmeow.MediaImage) - if media == nil { - return nil, sender, extraMeta, fmt.Errorf("failed to handle image #%d: %w", i+1, err) - } - imageMsg := &waProto.ImageMessage{ - ContextInfo: ctxInfo, - JPEGThumbnail: media.Thumbnail, - URL: &media.URL, - DirectPath: &media.DirectPath, - MediaKey: media.MediaKey, - Mimetype: &part.GetInfo().MimeType, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - if i == 0 { - msg.ImageMessage = imageMsg - } else { - extraMeta.GalleryExtraParts = append(extraMeta.GalleryExtraParts, &waProto.Message{ - ImageMessage: imageMsg, - }) - } - } - case event.MessageType(event.EventSticker.Type): - media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage) - if media == nil { - return nil, sender, extraMeta, err - } - extraMeta.MediaHandle = media.Handle - ctxInfo.MentionedJID = media.MentionedJIDs - msg.StickerMessage = &waProto.StickerMessage{ - ContextInfo: ctxInfo, - PngThumbnail: media.Thumbnail, - URL: &media.URL, - DirectPath: &media.DirectPath, - MediaKey: media.MediaKey, - Mimetype: &content.GetInfo().MimeType, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - case event.MsgVideo: - gifPlayback := content.GetInfo().MimeType == "image/gif" - media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaVideo) - if media == nil { - return nil, sender, extraMeta, err - } - duration := uint32(content.GetInfo().Duration / 1000) - extraMeta.MediaHandle = media.Handle - ctxInfo.MentionedJID = media.MentionedJIDs - msg.VideoMessage = &waProto.VideoMessage{ - ContextInfo: ctxInfo, - Caption: &media.Caption, - JPEGThumbnail: media.Thumbnail, - URL: &media.URL, - DirectPath: &media.DirectPath, - MediaKey: media.MediaKey, - Mimetype: &content.GetInfo().MimeType, - GifPlayback: &gifPlayback, - Seconds: &duration, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - case event.MsgAudio: - media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaAudio) - if media == nil { - return nil, sender, extraMeta, err - } - extraMeta.MediaHandle = media.Handle - duration := uint32(content.GetInfo().Duration / 1000) - msg.AudioMessage = &waProto.AudioMessage{ - ContextInfo: ctxInfo, - URL: &media.URL, - DirectPath: &media.DirectPath, - MediaKey: media.MediaKey, - Mimetype: &content.GetInfo().MimeType, - Seconds: &duration, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - _, isMSC3245Voice := evt.Content.Raw["org.matrix.msc3245.voice"] - if isMSC3245Voice { - msg.AudioMessage.Waveform = getUnstableWaveform(evt.Content.Raw) - msg.AudioMessage.PTT = proto.Bool(true) - // hacky hack to add the codecs param that whatsapp seems to require - msg.AudioMessage.Mimetype = proto.String(addCodecToMime(content.GetInfo().MimeType, "opus")) - } - case event.MsgFile: - media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaDocument) - if media == nil { - return nil, sender, extraMeta, err - } - extraMeta.MediaHandle = media.Handle - msg.DocumentMessage = &waProto.DocumentMessage{ - ContextInfo: ctxInfo, - Caption: &media.Caption, - JPEGThumbnail: media.Thumbnail, - URL: &media.URL, - DirectPath: &media.DirectPath, - Title: &media.FileName, - FileName: &media.FileName, - MediaKey: media.MediaKey, - Mimetype: &content.GetInfo().MimeType, - FileEncSHA256: media.FileEncSHA256, - FileSHA256: media.FileSHA256, - FileLength: proto.Uint64(uint64(media.FileLength)), - } - if media.Caption != "" { - msg.DocumentWithCaptionMessage = &waProto.FutureProofMessage{ - Message: &waProto.Message{ - DocumentMessage: msg.DocumentMessage, - }, - } - msg.DocumentMessage = nil - } - case event.MsgLocation: - lat, long, err := parseGeoURI(content.GeoURI) - if err != nil { - return nil, sender, extraMeta, fmt.Errorf("%w: %v", errInvalidGeoURI, err) - } - msg.LocationMessage = &waProto.LocationMessage{ - DegreesLatitude: &lat, - DegreesLongitude: &long, - Comment: &content.Body, - ContextInfo: ctxInfo, - } - default: - return nil, sender, extraMeta, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType) - } - - if editRootMsg != nil { - msg = &waProto.Message{ - EditedMessage: &waProto.FutureProofMessage{ - Message: &waProto.Message{ - ProtocolMessage: &waProto.ProtocolMessage{ - Key: &waProto.MessageKey{ - FromMe: proto.Bool(true), - ID: proto.String(editRootMsg.JID), - RemoteJID: proto.String(portal.Key.JID.String()), - }, - Type: waProto.ProtocolMessage_MESSAGE_EDIT.Enum(), - EditedMessage: msg, - TimestampMS: proto.Int64(evt.Timestamp), - }, - }, - }, - } - } - - return msg, sender, extraMeta, nil -} - -func (portal *Portal) generateMessageInfo(sender *User) *types.MessageInfo { - return &types.MessageInfo{ - ID: sender.Client.GenerateMessageID(), - Timestamp: time.Now(), - MessageSource: types.MessageSource{ - Sender: sender.JID, - Chat: portal.Key.JID, - IsFromMe: true, - IsGroup: portal.Key.JID.Server == types.GroupServer || portal.Key.JID.Server == types.BroadcastServer, - }, - } -} - -func (portal *Portal) HandleMatrixMessage(ctx context.Context, sender *User, evt *event.Event, timings messageTimings) { - start := time.Now() - ms := metricSender{portal: portal, timings: &timings} - log := zerolog.Ctx(ctx) - - allowRelay := evt.Type != TypeMSC3381PollResponse && evt.Type != TypeMSC3381V2PollResponse && evt.Type != TypeMSC3381PollStart - if err := portal.canBridgeFrom(sender, allowRelay, true); err != nil { - go ms.sendMessageMetrics(ctx, evt, err, "Ignoring", true) - return - } else if portal.Key.JID == types.StatusBroadcastJID && portal.bridge.Config.Bridge.DisableStatusBroadcastSend { - go ms.sendMessageMetrics(ctx, evt, errBroadcastSendDisabled, "Ignoring", true) - return - } - - messageAge := timings.totalReceive - origEvtID := evt.ID - var dbMsg *database.Message - if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { - origEvtID = retryMeta.OriginalEventID - var err error - logEvt := log.Debug(). - Dur("message_age", messageAge). - Int("retry_count", retryMeta.RetryCount). - Stringer("orig_event_id", origEvtID) - dbMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, origEvtID) - if err != nil { - log.Err(err).Msg("Failed to get retry request target message from database") - // TODO drop message? - } else if dbMsg != nil && dbMsg.Sent { - logEvt. - Str("wa_message_id", dbMsg.JID). - Msg("Ignoring retry request as message was already sent") - go ms.sendMessageMetrics(ctx, evt, nil, "", true) - return - } else if dbMsg != nil { - logEvt. - Str("wa_message_id", dbMsg.JID). - Msg("Got retry request for message") - } else { - logEvt.Msg("Got retry request for message, but original message is not known") - } - } else { - log.Debug().Dur("message_age", messageAge).Msg("Received Matrix message") - } - - errorAfter := portal.bridge.Config.Bridge.MessageHandlingTimeout.ErrorAfter - deadline := portal.bridge.Config.Bridge.MessageHandlingTimeout.Deadline - isScheduled, _ := evt.Content.Raw["com.beeper.scheduled"].(bool) - if isScheduled { - log.Debug().Msg("Message is a scheduled message, extending handling timeouts") - errorAfter *= 10 - deadline *= 10 - } - - if errorAfter > 0 { - remainingTime := errorAfter - messageAge - if remainingTime < 0 { - go ms.sendMessageMetrics(ctx, evt, errTimeoutBeforeHandling, "Timeout handling", true) - return - } else if remainingTime < 1*time.Second { - log.Warn(). - Dur("remaining_timeout", remainingTime). - Dur("warning_total_timeout", errorAfter). - Msg("Message was delayed before reaching the bridge") - } - go func() { - time.Sleep(remainingTime) - ms.sendMessageMetrics(ctx, evt, errMessageTakingLong, "Timeout handling", false) - }() - } - - timedCtx := ctx - if deadline > 0 { - var cancel context.CancelFunc - timedCtx, cancel = context.WithTimeout(ctx, deadline) - defer cancel() - } - - timings.preproc = time.Since(start) - start = time.Now() - msg, sender, extraMeta, err := portal.convertMatrixMessage(timedCtx, sender, evt) - timings.convert = time.Since(start) - if msg == nil { - go ms.sendMessageMetrics(ctx, evt, err, "Error converting", true) - return - } - if extraMeta == nil { - extraMeta = &extraConvertMeta{} - } - dbMsgType := database.MsgNormal - if msg.PollCreationMessage != nil || msg.PollCreationMessageV2 != nil || msg.PollCreationMessageV3 != nil { - dbMsgType = database.MsgMatrixPoll - } else if msg.EditedMessage == nil { - portal.MarkDisappearing(ctx, origEvtID, time.Duration(portal.ExpirationTime)*time.Second, time.Now()) - } else { - dbMsgType = database.MsgEdit - } - info := portal.generateMessageInfo(sender) - if dbMsg == nil { - dbMsg = portal.markHandled(ctx, nil, info, evt.ID, evt.Sender, false, true, dbMsgType, 0, database.MsgNoError) - } else { - info.ID = dbMsg.JID - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("wa_message_id", info.ID) - }) - if dbMsgType == database.MsgMatrixPoll && extraMeta.PollOptions != nil { - err = dbMsg.PutPollOptions(ctx, extraMeta.PollOptions) - if err != nil { - log.Err(err).Msg("Failed to save poll options in message to database") - } - } - log.Debug().Msg("Sending Matrix event to WhatsApp") - start = time.Now() - resp, err := sender.Client.SendMessage(timedCtx, portal.Key.JID, msg, whatsmeow.SendRequestExtra{ - ID: info.ID, - MediaHandle: extraMeta.MediaHandle, - }) - timings.totalSend = time.Since(start) - timings.whatsmeow = resp.DebugTimings - if err != nil { - go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true) - return - } - err = dbMsg.MarkSent(ctx, resp.Timestamp) - if err != nil { - log.Err(err).Msg("Failed to mark message as sent in database") - } - if extraMeta != nil && len(extraMeta.GalleryExtraParts) > 0 { - for i, part := range extraMeta.GalleryExtraParts { - partInfo := portal.generateMessageInfo(sender) - partDBMsg := portal.markHandled(ctx, nil, partInfo, evt.ID, evt.Sender, false, true, database.MsgBeeperGallery, i+1, database.MsgNoError) - log.Debug().Int("part_index", i+1).Str("wa_part_message_id", partInfo.ID).Msg("Sending gallery part to WhatsApp") - resp, err = sender.Client.SendMessage(timedCtx, portal.Key.JID, part, whatsmeow.SendRequestExtra{ID: partInfo.ID}) - if err != nil { - go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true) - return - } - log.Debug().Int("part_index", i+1).Str("wa_part_message_id", partInfo.ID).Msg("Sent gallery part to WhatsApp") - err = partDBMsg.MarkSent(ctx, resp.Timestamp) - if err != nil { - log.Err(err). - Str("part_id", partInfo.ID). - Msg("Failed to mark gallery extra part as sent in database") - } - } - } - go ms.sendMessageMetrics(ctx, evt, nil, "", true) -} - -func (portal *Portal) HandleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - if err := portal.canBridgeFrom(sender, false, true); err != nil { - go portal.sendMessageMetrics(ctx, evt, err, "Ignoring", nil) - return - } else if portal.Key.JID.Server == types.BroadcastServer { - // TODO implement this, probably by only sending the reaction to the sender of the status message? - // (whatsapp hasn't published the feature yet) - go portal.sendMessageMetrics(ctx, evt, errBroadcastReactionNotSupported, "Ignoring", nil) - return - } - - content, ok := evt.Content.Parsed.(*event.ReactionEventContent) - if ok && strings.Contains(content.RelatesTo.Key, "retry") || strings.HasPrefix(content.RelatesTo.Key, "\u267b") { // ♻️ - if retryRequested, _ := portal.requestMediaRetry(ctx, sender, content.RelatesTo.EventID, nil); retryRequested { - _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, evt.ID, mautrix.ReqRedact{ - Reason: "requested media from phone", - }) - // Errored media, don't try to send as reaction - return - } - } - - log.Debug().Msg("Received Matrix reaction event") - err := portal.handleMatrixReaction(ctx, sender, evt) - go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil) -} - -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) error { - log := zerolog.Ctx(ctx) - content, ok := evt.Content.Parsed.(*event.ReactionEventContent) - if !ok { - return fmt.Errorf("unexpected parsed content type %T", evt.Content.Parsed) - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("target_event_id", content.RelatesTo.EventID) - }) - target, err := portal.bridge.DB.Message.GetByMXID(ctx, content.RelatesTo.EventID) - if err != nil { - log.Err(err).Msg("Failed to get target message from database") - return fmt.Errorf("failed to get target event") - } else if target == nil || target.Type == database.MsgReaction { - return fmt.Errorf("unknown target event %s", content.RelatesTo.EventID) - } - info := portal.generateMessageInfo(sender) - dbMsg := portal.markHandled(ctx, nil, info, evt.ID, evt.Sender, false, true, database.MsgReaction, 0, database.MsgNoError) - portal.upsertReaction(ctx, nil, target.JID, sender.JID, evt.ID, info.ID) - log.Debug().Str("whatsapp_reaction_id", info.ID).Msg("Sending Matrix reaction to WhatsApp") - resp, err := portal.sendReactionToWhatsApp(sender, info.ID, target, content.RelatesTo.Key, evt.Timestamp) - if err == nil { - err = dbMsg.MarkSent(ctx, resp.Timestamp) - } - return err -} - -func (portal *Portal) sendReactionToWhatsApp(sender *User, id types.MessageID, target *database.Message, key string, timestamp int64) (whatsmeow.SendResponse, error) { - var messageKeyParticipant *string - if !portal.IsPrivateChat() { - messageKeyParticipant = proto.String(target.Sender.ToNonAD().String()) - } - key = variationselector.Remove(key) - ctx, cancel := context.WithTimeout(context.TODO(), 60*time.Second) - defer cancel() - return sender.Client.SendMessage(ctx, portal.Key.JID, &waProto.Message{ - ReactionMessage: &waProto.ReactionMessage{ - Key: &waProto.MessageKey{ - RemoteJID: proto.String(portal.Key.JID.String()), - FromMe: proto.Bool(target.Sender.User == sender.JID.User), - ID: proto.String(target.JID), - Participant: messageKeyParticipant, - }, - Text: proto.String(key), - SenderTimestampMS: proto.Int64(timestamp), - }, - }, whatsmeow.SendRequestExtra{ID: id}) -} - -func (portal *Portal) upsertReaction(ctx context.Context, intent *appservice.IntentAPI, targetJID types.MessageID, senderJID types.JID, mxid id.EventID, jid types.MessageID) { - log := zerolog.Ctx(ctx) - dbReaction, err := portal.bridge.DB.Reaction.GetByTargetJID(ctx, portal.Key, targetJID, senderJID) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction from database for upsert") - return - } - if dbReaction == nil { - dbReaction = portal.bridge.DB.Reaction.New() - dbReaction.Chat = portal.Key - dbReaction.TargetJID = targetJID - dbReaction.Sender = senderJID - } else if intent != nil { - log.Debug(). - Stringer("old_reaction_mxid", dbReaction.MXID). - Msg("Redacting old Matrix reaction after new one was sent") - if intent != nil { - _, err = intent.RedactEvent(ctx, portal.MXID, dbReaction.MXID) - } - if intent == nil || errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, dbReaction.MXID) - } - if err != nil { - log.Err(err). - Stringer("old_reaction_mxid", dbReaction.MXID). - Msg("Failed to redact old reaction") - } - } - dbReaction.MXID = mxid - dbReaction.JID = jid - err = dbReaction.Upsert(ctx) - if err != nil { - log.Err(err).Msg("Failed to upsert reaction to database") - } -} - -func (portal *Portal) HandleMatrixRedaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - if err := portal.canBridgeFrom(sender, true, true); err != nil { - go portal.sendMessageMetrics(ctx, evt, err, "Ignoring", nil) - return - } - log.Debug().Msg("Received Matrix redaction") - - senderLogIdentifier := sender.MXID - if !sender.HasSession() { - sender = portal.GetRelayUser() - senderLogIdentifier += " (through relaybot)" - } - - msg, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target event from database") - go portal.sendMessageMetrics(ctx, evt, errTargetNotFound, "Ignoring", nil) - } else if msg == nil { - go portal.sendMessageMetrics(ctx, evt, errTargetNotFound, "Ignoring", nil) - } else if msg.IsFakeJID() { - go portal.sendMessageMetrics(ctx, evt, errTargetIsFake, "Ignoring", nil) - } else if portal.Key.JID == types.StatusBroadcastJID && portal.bridge.Config.Bridge.DisableStatusBroadcastSend { - go portal.sendMessageMetrics(ctx, evt, errBroadcastSendDisabled, "Ignoring", nil) - } else if msg.Type == database.MsgReaction { - if msg.Sender.User != sender.JID.User { - go portal.sendMessageMetrics(ctx, evt, errReactionSentBySomeoneElse, "Ignoring", nil) - } else if reaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts); err != nil { - log.Err(err).Msg("Failed to get target reaction from database") - go portal.sendMessageMetrics(ctx, evt, errReactionDatabaseNotFound, "Ignoring", nil) - } else if reaction == nil { - go portal.sendMessageMetrics(ctx, evt, errReactionDatabaseNotFound, "Ignoring", nil) - } else if reactionTarget, err := portal.bridge.DB.Message.GetByJID(ctx, reaction.Chat, reaction.TargetJID); err != nil { - log.Err(err).Msg("Failed to get target reaction's target message from database") - go portal.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Ignoring", nil) - } else if reactionTarget == nil { - go portal.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Ignoring", nil) - } else { - log.Debug().Str("reaction_target_message_id", msg.JID).Msg("Sending redaction of reaction to WhatsApp") - _, err = portal.sendReactionToWhatsApp(sender, "", reactionTarget, "", evt.Timestamp) - go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil) - } - } else { - key := &waProto.MessageKey{ - FromMe: proto.Bool(true), - ID: proto.String(msg.JID), - RemoteJID: proto.String(portal.Key.JID.String()), - } - if msg.Sender.User != sender.JID.User { - if portal.IsPrivateChat() { - go portal.sendMessageMetrics(ctx, evt, errDMSentByOtherUser, "Ignoring", nil) - return - } - key.FromMe = proto.Bool(false) - key.Participant = proto.String(msg.Sender.ToNonAD().String()) - } - log.Debug().Str("target_message_id", msg.JID).Msg("Sending redaction of message to WhatsApp") - timedCtx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - _, err = sender.Client.SendMessage(timedCtx, portal.Key.JID, &waProto.Message{ - ProtocolMessage: &waProto.ProtocolMessage{ - Type: waProto.ProtocolMessage_REVOKE.Enum(), - Key: key, - }, - }) - go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil) - } -} - -func (portal *Portal) HandleMatrixReadReceipt(sender bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - log := portal.zlog.With(). - Str("action", "handle matrix read receipt"). - Stringer("event_id", eventID). - Stringer("user_id", sender.GetMXID()). - Logger() - ctx := log.WithContext(context.TODO()) - portal.handleMatrixReadReceipt(ctx, sender.(*User), eventID, receipt.Timestamp, true) -} - -func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, sender *User, eventID id.EventID, receiptTimestamp time.Time, isExplicit bool) { - log := zerolog.Ctx(ctx).With(). - Stringer("sender_jid", sender.JID). - Logger() - if !sender.IsLoggedIn() { - if isExplicit { - log.Debug().Msg("Ignoring read receipt: user is not connected to WhatsApp") - } - return - } - - maxTimestamp := receiptTimestamp - // Implicit read receipts don't have an event ID that's already bridged - if isExplicit { - if message, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID); err != nil { - log.Err(err).Msg("Failed to get read receipt target message") - } else if message != nil { - maxTimestamp = message.Timestamp - } - } - - prevTimestamp := sender.GetLastReadTS(ctx, portal.Key) - lastReadIsZero := false - if prevTimestamp.IsZero() { - prevTimestamp = maxTimestamp.Add(-2 * time.Second) - lastReadIsZero = true - } - - messages, err := portal.bridge.DB.Message.GetMessagesBetween(ctx, portal.Key, prevTimestamp, maxTimestamp) - if err != nil { - log.Err(err).Msg("Failed to get messages that need receipts") - return - } - if len(messages) > 0 { - sender.SetLastReadTS(ctx, portal.Key, messages[len(messages)-1].Timestamp) - } - groupedMessages := make(map[types.JID][]types.MessageID) - for _, msg := range messages { - var key types.JID - if msg.IsFakeJID() || msg.Sender.User == sender.JID.User { - // Don't send read receipts for own messages or fake messages - continue - } else if !portal.IsPrivateChat() { - key = msg.Sender - } else if !msg.BroadcastListJID.IsEmpty() { - key = msg.BroadcastListJID - } // else: blank key (participant field isn't needed in direct chat read receipts) - groupedMessages[key] = append(groupedMessages[key], msg.JID) - } - // For explicit read receipts, log even if there are no targets. For implicit ones only log when there are targets - if len(groupedMessages) > 0 || isExplicit { - log.Debug(). - Bool("explicit", isExplicit). - Time("last_read", prevTimestamp). - Bool("last_read_is_zero", lastReadIsZero). - Any("receipts", groupedMessages). - Msg("Sending read receipts to WhatsApp") - } - for messageSender, ids := range groupedMessages { - chatJID := portal.Key.JID - if messageSender.Server == types.BroadcastServer { - chatJID = messageSender - messageSender = portal.Key.JID - } - err = sender.Client.MarkRead(ids, receiptTimestamp, chatJID, messageSender) - if err != nil { - log.Err(err). - Array("message_ids", exzerolog.ArrayOfStrs(ids)). - Stringer("target_user_jid", messageSender). - Msg("Failed to send read receipt") - } - } -} - -func typingDiff(prev, new []id.UserID) (started, stopped []id.UserID) { -OuterNew: - for _, userID := range new { - for _, previousUserID := range prev { - if userID == previousUserID { - continue OuterNew - } - } - started = append(started, userID) - } -OuterPrev: - for _, userID := range prev { - for _, previousUserID := range new { - if userID == previousUserID { - continue OuterPrev - } - } - stopped = append(stopped, userID) - } - return -} - -func (portal *Portal) setTyping(userIDs []id.UserID, state types.ChatPresence) { - for _, userID := range userIDs { - user := portal.bridge.GetUserByMXIDIfExists(userID) - if user == nil || !user.IsLoggedIn() { - continue - } - portal.zlog.Debug(). - Stringer("user_jid", user.JID). - Stringer("user_mxid", user.MXID). - Str("state", string(state)). - Msg("Bridging typing change to chat presence") - err := user.Client.SendChatPresence(portal.Key.JID, state, types.ChatPresenceMediaText) - if err != nil { - portal.zlog.Err(err). - Stringer("user_jid", user.JID). - Stringer("user_mxid", user.MXID). - Str("state", string(state)). - Msg("Failed to send chat presence") - } - if portal.bridge.Config.Bridge.SendPresenceOnTyping { - err = user.Client.SendPresence(types.PresenceAvailable) - if err != nil { - user.zlog.Warn().Err(err).Msg("Failed to set presence on typing") - } - } - } -} - -func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) { - portal.currentlyTypingLock.Lock() - defer portal.currentlyTypingLock.Unlock() - startedTyping, stoppedTyping := typingDiff(portal.currentlyTyping, newTyping) - portal.currentlyTyping = newTyping - portal.setTyping(startedTyping, types.ChatPresenceComposing) - portal.setTyping(stoppedTyping, types.ChatPresencePaused) -} - -func (portal *Portal) canBridgeFrom(sender *User, allowRelay, reconnectWait bool) error { - if !sender.IsLoggedIn() { - if allowRelay && portal.HasRelaybot() { - return nil - } else if sender.Session != nil { - return errUserNotConnected - } else if reconnectWait { - // If a message was received exactly during a disconnection, wait a second for the socket to reconnect - time.Sleep(1 * time.Second) - return portal.canBridgeFrom(sender, allowRelay, false) - } else { - return errUserNotLoggedIn - } - } else if portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User && (!allowRelay || !portal.HasRelaybot()) { - return errDifferentUser - } - return nil -} - -func (portal *Portal) resetChildSpaceStatus() { - for _, childPortal := range portal.bridge.portalsByJID { - if childPortal.ParentGroup == portal.Key.JID { - if portal.MXID != "" && childPortal.InSpace { - go childPortal.removeSpaceParentEvent(portal.MXID) - } - childPortal.InSpace = false - if childPortal.parentPortal == portal { - childPortal.parentPortal = nil - } - } - } -} - -func (portal *Portal) Delete(ctx context.Context) { - err := portal.Portal.Delete(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to delete portal from database") - } - portal.bridge.portalsLock.Lock() - delete(portal.bridge.portalsByJID, portal.Key) - if len(portal.MXID) > 0 { - delete(portal.bridge.portalsByMXID, portal.MXID) - } - portal.resetChildSpaceStatus() - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) GetMatrixUsers(ctx context.Context) ([]id.UserID, error) { - members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) - if err != nil { - return nil, fmt.Errorf("failed to get member list: %w", err) - } - var users []id.UserID - for userID := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(userID) - if !isPuppet && userID != portal.bridge.Bot.UserID { - users = append(users, userID) - } - } - return users, nil -} - -func (portal *Portal) CleanupIfEmpty(ctx context.Context) { - users, err := portal.GetMatrixUsers(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up") - return - } - - if len(users) == 0 { - zerolog.Ctx(ctx).Info().Msg("Room seems to be empty, cleaning up...") - portal.Delete(ctx) - portal.Cleanup(ctx, false) - } -} - -func (portal *Portal) Cleanup(ctx context.Context, puppetsOnly bool) { - if len(portal.MXID) == 0 { - return - } - log := zerolog.Ctx(ctx) - intent := portal.MainIntent() - if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := intent.BeeperDeleteRoom(ctx, portal.MXID) - if err == nil || errors.Is(err, mautrix.MNotFound) { - return - } - log.Warn().Err(err).Msg("Failed to delete room using beeper yeet endpoint, falling back to normal behavior") - } - members, err := intent.JoinedMembers(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Failed to get portal members for cleanup") - return - } - for member := range members.Joined { - if member == intent.UserID { - continue - } - puppet := portal.bridge.GetPuppetByMXID(member) - if puppet != nil { - _, err = puppet.DefaultIntent().LeaveRoom(ctx, portal.MXID) - if err != nil { - log.Err(err).Stringer("puppet_mxid", puppet.MXID).Msg("Failed to leave room as puppet while cleaning up portal") - } - } else if !puppetsOnly { - _, err = intent.KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - log.Err(err).Stringer("user_mxid", member).Msg("Failed to kick user while cleaning up portal") - } - } - } - _, err = intent.LeaveRoom(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Failed to leave room with main intent while cleaning up portal") - } -} - -func (portal *Portal) HandleMatrixLeave(brSender bridge.User, evt *event.Event) { - log := portal.zlog.With(). - Str("action", "handle matrix leave"). - Stringer("event_id", evt.ID). - Stringer("user_id", brSender.GetMXID()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - if portal.IsPrivateChat() { - log.Debug().Msg("User left private chat portal, cleaning up and deleting...") - portal.Delete(ctx) - portal.Cleanup(ctx, false) - return - } else if portal.bridge.Config.Bridge.BridgeMatrixLeave { - err := sender.Client.LeaveGroup(portal.Key.JID) - if err != nil { - log.Err(err).Msg("Failed to leave group") - return - } - //portal.log.Infoln("Leave response:", <-resp) - } - portal.CleanupIfEmpty(ctx) -} - -func (portal *Portal) HandleMatrixKick(brSender bridge.User, brTarget bridge.Ghost, evt *event.Event) { - sender := brSender.(*User) - target := brTarget.(*Puppet) - _, err := sender.Client.UpdateGroupParticipants(portal.Key.JID, []types.JID{target.JID}, whatsmeow.ParticipantChangeRemove) - if err != nil { - portal.zlog.Err(err). - Stringer("kicked_by_mxid", sender.MXID). - Stringer("kicked_by_jid", sender.JID). - Stringer("target_jid", target.JID). - Msg("Failed to kick user from group") - return - } - //portal.log.Infoln("Kick %s response: %s", puppet.JID, <-resp) -} - -func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brTarget bridge.Ghost, evt *event.Event) { - sender := brSender.(*User) - target := brTarget.(*Puppet) - _, err := sender.Client.UpdateGroupParticipants(portal.Key.JID, []types.JID{target.JID}, whatsmeow.ParticipantChangeAdd) - if err != nil { - portal.zlog.Err(err). - Stringer("inviter_mxid", sender.MXID). - Stringer("inviter_jid", sender.JID). - Stringer("target_jid", target.JID). - Msg("Failed to add user to group") - return - } - //portal.log.Infofln("Add %s response: %s", puppet.JID, <-resp) -} - -func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) { - sender := brSender.(*User) - if !sender.Whitelisted || !sender.IsLoggedIn() { - return - } - log := portal.zlog.With(). - Str("action", "handle matrix metadata"). - Str("event_type", evt.Type.Type). - Stringer("event_id", evt.ID). - Stringer("sender", sender.MXID). - Logger() - ctx := log.WithContext(context.TODO()) - - switch content := evt.Content.Parsed.(type) { - case *event.RoomNameEventContent: - if content.Name == portal.Name { - return - } - portal.Name = content.Name - err := sender.Client.SetGroupName(portal.Key.JID, content.Name) - if err != nil { - log.Err(err).Msg("Failed to update group name") - return - } - case *event.TopicEventContent: - if content.Topic == portal.Topic { - return - } - portal.Topic = content.Topic - err := sender.Client.SetGroupTopic(portal.Key.JID, "", "", content.Topic) - if err != nil { - log.Err(err).Msg("Failed to update group topic") - return - } - case *event.RoomAvatarEventContent: - portal.avatarLock.Lock() - defer portal.avatarLock.Unlock() - url := content.URL.ParseOrIgnore() - if url == portal.AvatarURL || (url.IsEmpty() && portal.Avatar == "remove") { - return - } - var data []byte - var err error - if !url.IsEmpty() { - data, err = portal.MainIntent().DownloadBytes(ctx, url) - if err != nil { - log.Err(err).Stringer("mxc_uri", url).Msg("Failed to download updated avatar") - return - } - log.Debug().Stringer("mxc_uri", url).Msg("Updating group avatar") - } else { - log.Debug().Msg("Removing group avatar") - } - newID, err := sender.Client.SetGroupPhoto(portal.Key.JID, data) - if err != nil { - log.Err(err).Msg("Failed to update group avatar") - return - } - log.Debug().Str("avatar_id", newID).Msg("Successfully updated group avatar") - portal.Avatar = newID - portal.AvatarURL = url - default: - log.Debug().Type("content_type", content).Msg("Ignoring unknown metadata event type") - return - } - portal.UpdateBridgeInfo(ctx) - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update portal after handling metadata") - } -} diff --git a/provisioning.go b/provisioning.go deleted file mode 100644 index 22e43ff..0000000 --- a/provisioning.go +++ /dev/null @@ -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 . - -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 - } - } -} diff --git a/puppet.go b/puppet.go deleted file mode 100644 index 46ba62a..0000000 --- a/puppet.go +++ /dev/null @@ -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 . - -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") - } - } -} diff --git a/urlpreview.go b/urlpreview.go deleted file mode 100644 index f40441d..0000000 --- a/urlpreview.go +++ /dev/null @@ -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 . - -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 -} diff --git a/user.go b/user.go deleted file mode 100644 index 4558b87..0000000 --- a/user.go +++ /dev/null @@ -1,1639 +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 . - -package main - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "math" - "math/rand" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exzerolog" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/appstate" - waProto "go.mau.fi/whatsmeow/binary/proto" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" - "golang.org/x/sync/semaphore" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "maunium.net/go/mautrix-whatsapp/database" -) - -type User struct { - *database.User - Client *whatsmeow.Client - Session *store.Device - - bridge *WABridge - zlog zerolog.Logger - - Admin bool - Whitelisted bool - RelayWhitelisted bool - PermissionLevel bridgeconfig.PermissionLevel - - mgmtCreateLock sync.Mutex - spaceCreateLock sync.Mutex - connLock sync.Mutex - - historySyncs chan *events.HistorySync - lastPresence types.Presence - - mediaRetryLock *semaphore.Weighted - - historySyncLoopsStarted bool - enqueueBackfillsTimer *time.Timer - spaceMembershipChecked bool - lastPhoneOfflineWarning time.Time - - groupListCache []*types.GroupInfo - groupListCacheLock sync.Mutex - groupListCacheTime time.Time - - BackfillQueue *BackfillQueue - BridgeState *bridge.BridgeStateQueue - - resyncQueue map[types.JID]resyncQueueItem - resyncQueueLock sync.Mutex - nextResync time.Time - - createKeyDedup string - skipGroupCreateDelay types.JID - groupJoinLock sync.Mutex - - qrReceived chan struct{} - qrWaiting atomic.Bool -} - -type resyncQueueItem struct { - portal *Portal - puppet *Puppet -} - -func (br *WABridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { - _, isPuppet := br.ParsePuppetMXID(userID) - if isPuppet || userID == br.Bot.UserID { - return nil - } - br.usersLock.Lock() - defer br.usersLock.Unlock() - user, ok := br.usersByMXID[userID] - if !ok { - userIDPtr := &userID - if onlyIfExists { - userIDPtr = nil - } - ctx := context.TODO() - dbUser, err := br.DB.User.GetByMXID(ctx, userID) - if err != nil { - br.ZLog.Err(err).Stringer("mxid", userID).Msg("Failed to get user by MXID from database") - return nil - } - return br.loadDBUser(ctx, dbUser, userIDPtr) - } - return user -} - -func (br *WABridge) GetUserByMXID(userID id.UserID) *User { - return br.getUserByMXID(userID, false) -} - -func (br *WABridge) GetIUser(userID id.UserID, create bool) bridge.User { - u := br.getUserByMXID(userID, !create) - if u == nil { - return nil - } - return u -} - -func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { - return user.PermissionLevel -} - -func (user *User) GetManagementRoomID() id.RoomID { - return user.ManagementRoom -} - -func (user *User) GetMXID() id.UserID { - return user.MXID -} - -func (user *User) GetCommandState() map[string]interface{} { - return nil -} - -func (br *WABridge) GetUserByMXIDIfExists(userID id.UserID) *User { - return br.getUserByMXID(userID, true) -} - -func (br *WABridge) GetUserByJID(jid types.JID) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - user, ok := br.usersByUsername[jid.User] - if !ok { - ctx := context.TODO() - dbUser, err := br.DB.User.GetByUsername(ctx, jid.User) - if err != nil { - br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to get user by JID from database") - return nil - } - return br.loadDBUser(ctx, dbUser, nil) - } - return user -} - -func (user *User) addToJIDMap() { - user.bridge.usersLock.Lock() - user.bridge.usersByUsername[user.JID.User] = user - user.bridge.usersLock.Unlock() -} - -func (user *User) removeFromJIDMap(state status.BridgeState) { - user.bridge.usersLock.Lock() - jidUser, ok := user.bridge.usersByUsername[user.JID.User] - if ok && user == jidUser { - delete(user.bridge.usersByUsername, user.JID.User) - } - user.bridge.usersLock.Unlock() - user.bridge.Metrics.TrackLoginState(user.JID, false) - user.BridgeState.Send(state) -} - -func (br *WABridge) GetAllUsers() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - ctx := context.TODO() - dbUsers, err := br.DB.User.GetAll(ctx) - if err != nil { - br.ZLog.Error().Err(err).Msg("Failed to get all users from database") - return nil - } - output := make([]*User, len(dbUsers)) - for index, dbUser := range dbUsers { - user, ok := br.usersByMXID[dbUser.MXID] - if !ok { - user = br.loadDBUser(ctx, dbUser, nil) - } - output[index] = user - } - return output -} - -func (br *WABridge) loadDBUser(ctx context.Context, dbUser *database.User, mxid *id.UserID) *User { - if dbUser == nil { - if mxid == nil { - return nil - } - dbUser = br.DB.User.New() - dbUser.MXID = *mxid - err := dbUser.Insert(ctx) - if err != nil { - br.ZLog.Error().Err(err).Msg("Failed to insert new user into database") - return nil - } - } - user := br.NewUser(dbUser) - br.usersByMXID[user.MXID] = user - if !user.JID.IsEmpty() { - var err error - user.Session, err = br.WAContainer.GetDevice(user.JID) - if err != nil { - user.zlog.Err(err).Msg("Failed to load user's whatsapp session") - } else if user.Session == nil { - user.zlog.Warn().Stringer("jid", user.JID).Msg("Didn't find session data for user's JID, treating user as logged out") - user.JID = types.EmptyJID - err = user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after clearing JID") - } - } else { - user.Session.Log = waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow").Str("db_section", "whatsmeow").Logger()) - br.usersByUsername[user.JID.User] = user - } - } - if len(user.ManagementRoom) > 0 { - br.managementRooms[user.ManagementRoom] = user - } - return user -} - -func (br *WABridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - zlog: br.ZLog.With().Str("user_id", dbUser.MXID.String()).Logger(), - - historySyncs: make(chan *events.HistorySync, 32), - lastPresence: types.PresenceUnavailable, - - resyncQueue: make(map[types.JID]resyncQueueItem), - - mediaRetryLock: semaphore.NewWeighted(br.Config.Bridge.HistorySync.MediaRequests.MaxAsyncHandle), - } - - user.PermissionLevel = user.bridge.Config.Bridge.Permissions.Get(user.MXID) - user.RelayWhitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelRelay - user.Whitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelUser - user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin - user.BridgeState = br.NewBridgeStateQueue(user) - user.enqueueBackfillsTimer = time.NewTimer(5 * time.Second) - user.enqueueBackfillsTimer.Stop() - go user.puppetResyncLoop() - return user -} - -const resyncMinInterval = 7 * 24 * time.Hour -const resyncLoopInterval = 4 * time.Hour - -func (user *User) puppetResyncLoop() { - user.nextResync = time.Now().Add(resyncLoopInterval).Add(-time.Duration(rand.Intn(3600)) * time.Second) - for { - time.Sleep(user.nextResync.Sub(time.Now())) - user.nextResync = time.Now().Add(resyncLoopInterval) - user.doPuppetResync() - } -} - -func (user *User) EnqueuePuppetResync(puppet *Puppet) { - if puppet.LastSync.Add(resyncMinInterval).After(time.Now()) { - return - } - user.resyncQueueLock.Lock() - if _, exists := user.resyncQueue[puppet.JID]; !exists { - user.resyncQueue[puppet.JID] = resyncQueueItem{puppet: puppet} - user.zlog.Debug(). - Stringer("jid", puppet.JID). - Str("next_resync", time.Until(user.nextResync).String()). - Msg("Enqueued resync for puppet") - } - user.resyncQueueLock.Unlock() -} - -func (user *User) EnqueuePortalResync(portal *Portal) { - if !portal.IsGroupChat() || portal.LastSync.Add(resyncMinInterval).After(time.Now()) { - return - } - user.resyncQueueLock.Lock() - if _, exists := user.resyncQueue[portal.Key.JID]; !exists { - user.resyncQueue[portal.Key.JID] = resyncQueueItem{portal: portal} - user.zlog.Debug(). - Stringer("jid", portal.Key.JID). - Str("next_resync", time.Until(user.nextResync).String()). - Msg("Enqueued resync for portal") - } - user.resyncQueueLock.Unlock() -} - -func (user *User) doPuppetResync() { - if !user.IsLoggedIn() { - return - } - user.resyncQueueLock.Lock() - if len(user.resyncQueue) == 0 { - user.resyncQueueLock.Unlock() - return - } - log := user.zlog.With().Str("action", "puppet resync").Logger() - ctx := log.WithContext(context.TODO()) - queue := user.resyncQueue - user.resyncQueue = make(map[types.JID]resyncQueueItem) - user.resyncQueueLock.Unlock() - var puppetJIDs []types.JID - var puppets []*Puppet - var portals []*Portal - for jid, item := range queue { - var lastSync time.Time - if item.puppet != nil { - lastSync = item.puppet.LastSync - } else if item.portal != nil { - lastSync = item.portal.LastSync - } - if lastSync.Add(resyncMinInterval).After(time.Now()) { - log.Debug(). - Stringer("jid", jid). - Str("last_sync", time.Since(lastSync).String()). - Msg("Not resyncing, last sync was too recent") - continue - } - if item.puppet != nil { - puppets = append(puppets, item.puppet) - puppetJIDs = append(puppetJIDs, jid) - } else if item.portal != nil { - portals = append(portals, item.portal) - } - } - for _, portal := range portals { - groupInfo, err := user.Client.GetGroupInfo(portal.Key.JID) - if err != nil { - log.Warn().Err(err).Stringer("jid", portal.Key.JID).Msg("Failed to get group info for background sync") - } else { - log.Debug().Stringer("jid", portal.Key.JID).Msg("Doing background sync for group") - portal.UpdateMatrixRoom(ctx, user, groupInfo, nil) - } - } - if len(puppetJIDs) == 0 { - return - } - log.Debug().Array("jids", exzerolog.ArrayOfStringers(puppetJIDs)).Msg("Doing background sync for users") - infos, err := user.Client.GetUserInfo(puppetJIDs) - if err != nil { - log.Err(err).Msg("Failed to get user info for background sync") - return - } - for _, puppet := range puppets { - info, ok := infos[puppet.JID] - if !ok { - log.Warn().Stringer("jid", puppet.JID).Msg("Didn't get info for puppet in background sync") - continue - } - var contactPtr *types.ContactInfo - contact, err := user.Session.Contacts.GetContact(puppet.JID) - if err != nil { - log.Err(err).Stringer("jid", puppet.JID).Msg("Failed to get contact info for puppet in background sync") - } else if contact.Found { - contactPtr = &contact - } - puppet.Sync(ctx, user, contactPtr, info.PictureID != "" && info.PictureID != puppet.Avatar, true) - } -} - -func (user *User) ensureInvited(ctx context.Context, intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { - extraContent := make(map[string]interface{}) - if isDirect { - extraContent["is_direct"] = true - } - customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - extraContent["fi.mau.will_auto_accept"] = true - } - _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) - var httpErr mautrix.HTTPError - if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { - err = user.bridge.StateStore.SetMembership(ctx, roomID, user.MXID, event.MembershipJoin) - if err != nil { - user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to update membership to join in state store after invite failed") - } - ok = true - return - } else if err != nil { - user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to invite user to room") - } else { - ok = true - } - - if customPuppet != nil && customPuppet.CustomIntent() != nil { - err = customPuppet.CustomIntent().EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) - if err != nil { - user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to auto-join room") - ok = false - } else { - ok = true - } - } - return -} - -func (user *User) GetSpaceRoom(ctx context.Context) id.RoomID { - if !user.bridge.Config.Bridge.PersonalFilteringSpaces { - return "" - } - - if len(user.SpaceRoom) == 0 { - user.spaceCreateLock.Lock() - defer user.spaceCreateLock.Unlock() - if len(user.SpaceRoom) > 0 { - return user.SpaceRoom - } - - resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: "WhatsApp", - Topic: "Your WhatsApp bridged chats", - InitialState: []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: user.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - }, - }, - }}, - CreationContent: map[string]interface{}{ - "type": event.RoomTypeSpace, - }, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - user.bridge.Bot.UserID: 9001, - user.MXID: 50, - }, - }, - }) - - if err != nil { - user.zlog.Err(err).Msg("Failed to auto-create space room") - } else { - user.SpaceRoom = resp.RoomID - err = user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after creating space room") - } - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - } else if !user.spaceMembershipChecked && !user.bridge.StateStore.IsInRoom(ctx, user.SpaceRoom, user.MXID) { - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - user.spaceMembershipChecked = true - - return user.SpaceRoom -} - -func (user *User) GetManagementRoom(ctx context.Context) id.RoomID { - if len(user.ManagementRoom) == 0 { - user.mgmtCreateLock.Lock() - defer user.mgmtCreateLock.Unlock() - if len(user.ManagementRoom) > 0 { - return user.ManagementRoom - } - creationContent := make(map[string]interface{}) - if !user.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - Topic: "WhatsApp bridge notices", - IsDirect: true, - CreationContent: creationContent, - }) - if err != nil { - user.zlog.Err(err).Msg("Failed to auto-create management room") - } else { - user.SetManagementRoom(resp.RoomID) - } - } - return user.ManagementRoom -} - -func (user *User) SetManagementRoom(roomID id.RoomID) { - ctx := context.TODO() - - existingUser, ok := user.bridge.managementRooms[roomID] - if ok { - existingUser.ManagementRoom = "" - err := existingUser.Update(ctx) - if err != nil { - user.zlog.Err(err). - Stringer("other_user_mxid", existingUser.MXID). - Msg("Failed to save previous user after removing from old management room") - } - } - - user.ManagementRoom = roomID - user.bridge.managementRooms[user.ManagementRoom] = user - err := user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after setting management room") - } -} - -var ErrAlreadyLoggedIn = errors.New("already logged in") - -func (user *User) obfuscateJID(jid types.JID) string { - // Turn the first 4 bytes of HMAC-SHA256(hs_token, phone) into a number and replace the middle of the actual phone with that deterministic random number. - randomNumber := binary.BigEndian.Uint32(hmac.New(sha256.New, []byte(user.bridge.Config.AppService.HSToken)).Sum([]byte(jid.User))[:4]) - return fmt.Sprintf("+%s-%d-%s:%d", jid.User[:1], randomNumber, jid.User[len(jid.User)-2:], jid.Device) -} - -func (user *User) createClient(sess *store.Device) { - user.Client = whatsmeow.NewClient(sess, waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow").Logger())) - user.qrReceived = make(chan struct{}) - user.qrWaiting.Store(true) - user.Client.AddEventHandler(user.HandleEvent) - user.Client.SetForceActiveDeliveryReceipts(user.bridge.Config.Bridge.ForceActiveDeliveryReceipts) - user.Client.AutomaticMessageRerequestFromPhone = true - user.Client.GetMessageForRetry = func(requester, to types.JID, id types.MessageID) *waProto.Message { - Analytics.Track(user.MXID, "WhatsApp incoming retry (message not found)", map[string]interface{}{ - "requester": user.obfuscateJID(requester), - "messageID": id, - }) - user.bridge.Metrics.TrackRetryReceipt(0, false) - return nil - } - user.Client.PreRetryCallback = func(receipt *events.Receipt, messageID types.MessageID, retryCount int, msg *waProto.Message) bool { - Analytics.Track(user.MXID, "WhatsApp incoming retry (accepted)", map[string]interface{}{ - "requester": user.obfuscateJID(receipt.Sender), - "messageID": messageID, - "retryCount": retryCount, - }) - user.bridge.Metrics.TrackRetryReceipt(retryCount, true) - return true - } - if !user.bridge.Config.WhatsApp.ProxyOnlyLogin || sess.ID == nil { - if proxy, err := user.getProxy("login"); err != nil { - user.zlog.Err(err).Msg("Failed to get proxy address") - } else if err = user.Client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{ - NoMedia: user.bridge.Config.WhatsApp.ProxyOnlyLogin, - }); err != nil { - user.zlog.Err(err).Msg("Failed to set proxy address") - } - } - if user.bridge.Config.WhatsApp.ProxyOnlyLogin { - user.Client.ToggleProxyOnlyForLogin(true) - } -} - -type respGetProxy struct { - ProxyURL string `json:"proxy_url"` -} - -func (user *User) getProxy(reason string) (string, error) { - if user.bridge.Config.WhatsApp.GetProxyURL == "" { - return user.bridge.Config.WhatsApp.Proxy, nil - } - parsed, err := url.Parse(user.bridge.Config.WhatsApp.GetProxyURL) - if err != nil { - return "", fmt.Errorf("failed to parse address: %w", err) - } - q := parsed.Query() - q.Set("reason", reason) - parsed.RawQuery = q.Encode() - req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) - if err != nil { - return "", fmt.Errorf("failed to prepare request: %w", err) - } - req.Header.Set("User-Agent", mautrix.DefaultUserAgent) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { - return "", fmt.Errorf("unexpected status code %d", resp.StatusCode) - } - var respData respGetProxy - err = json.NewDecoder(resp.Body).Decode(&respData) - if err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - return respData.ProxyURL, nil -} - -func (user *User) Login(ctx context.Context) (<-chan whatsmeow.QRChannelItem, chan struct{}, error) { - user.connLock.Lock() - defer user.connLock.Unlock() - if user.Session != nil { - return nil, nil, ErrAlreadyLoggedIn - } else if user.Client != nil { - user.unlockedDeleteConnection() - } - newSession := user.bridge.WAContainer.NewDevice() - newSession.Log = waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow session").Logger()) - user.createClient(newSession) - qrChan, err := user.Client.GetQRChannel(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get QR channel: %w", err) - } - err = user.Client.Connect() - if err != nil { - return nil, nil, fmt.Errorf("failed to connect to WhatsApp: %w", err) - } - return qrChan, user.qrReceived, nil -} - -func (user *User) Connect() bool { - user.connLock.Lock() - defer user.connLock.Unlock() - if user.Client != nil { - return user.Client.IsConnected() - } else if user.Session == nil { - return false - } - user.zlog.Debug().Msg("Connecting to WhatsApp") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting, Error: WAConnecting}) - user.createClient(user.Session) - err := user.Client.Connect() - if err != nil { - user.zlog.Err(err).Msg("Error connecting to WhatsApp") - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: WAConnectionFailed, - Info: map[string]interface{}{ - "go_error": err.Error(), - }, - }) - return false - } - return true -} - -func (user *User) unlockedDeleteConnection() { - if user.Client == nil { - return - } - user.Client.Disconnect() - user.Client.RemoveEventHandlers() - user.Client = nil - user.bridge.Metrics.TrackConnectionState(user.JID, false) -} - -func (user *User) DeleteConnection() { - user.connLock.Lock() - defer user.connLock.Unlock() - user.unlockedDeleteConnection() -} - -func (user *User) HasSession() bool { - return user.Session != nil -} - -func (user *User) DeleteSession(ctx context.Context) { - log := zerolog.Ctx(ctx) - if user.Session != nil { - err := user.Session.Delete() - if err != nil { - log.Err(err).Msg("Failed to delete session") - } - user.Session = nil - } - if !user.JID.IsEmpty() { - user.JID = types.EmptyJID - err := user.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save user after clearing JID") - } - } - - // Delete all of the backfill and history sync data. - err := user.bridge.DB.BackfillQueue.DeleteAll(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to delete backfill queue data") - } - err = user.bridge.DB.HistorySync.DeleteAllConversations(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to delete historical conversation list") - } - err = user.bridge.DB.HistorySync.DeleteAllMessages(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to delete historical messages") - } - err = user.bridge.DB.MediaBackfillRequest.DeleteAllMediaBackfillRequests(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to delete media backfill requests") - } -} - -func (user *User) IsConnected() bool { - return user.Client != nil && user.Client.IsConnected() -} - -func (user *User) IsLoggedIn() bool { - return user.IsConnected() && user.Client.IsLoggedIn() -} - -func (user *User) sendMarkdownBridgeAlert(ctx context.Context, formatString string, args ...interface{}) { - if user.bridge.Config.Bridge.DisableBridgeAlerts { - return - } - notice := fmt.Sprintf(formatString, args...) - content := format.RenderMarkdown(notice, true, false) - _, err := user.bridge.Bot.SendMessageEvent(ctx, user.GetManagementRoom(ctx), event.EventMessage, content) - if err != nil { - user.zlog.Warn().Err(err).Str("notice", notice).Msg("Failed to send bridge alert") - } -} - -const callEventMaxAge = 15 * time.Minute - -func (user *User) handleCallStart(sender types.JID, id, callType string, ts time.Time) { - if !user.bridge.Config.Bridge.CallStartNotices || ts.Add(callEventMaxAge).Before(time.Now()) { - return - } - portal := user.GetPortalByJID(sender) - text := "Incoming call. Use the WhatsApp app to answer." - if callType != "" { - text = fmt.Sprintf("Incoming %s call. Use the WhatsApp app to answer.", callType) - } - portal.events <- &PortalEvent{ - Message: &PortalMessage{ - fake: &fakeMessage{ - Sender: sender, - Text: text, - ID: id, - Time: ts, - Important: true, - }, - source: user, - }, - } -} - -const PhoneDisconnectWarningTime = 12 * 24 * time.Hour // 12 days -const PhoneDisconnectPingTime = 10 * 24 * time.Hour -const PhoneMinPingInterval = 24 * time.Hour - -func (user *User) sendHackyPhonePing(ctx context.Context) { - user.PhoneLastPinged = time.Now() - msgID := user.Client.GenerateMessageID() - keyIDs := make([]*waProto.AppStateSyncKeyId, 0, 1) - lastKeyID, err := user.GetLastAppStateKeyID(ctx) - if lastKeyID != nil { - keyIDs = append(keyIDs, &waProto.AppStateSyncKeyId{ - KeyID: lastKeyID, - }) - } else { - user.zlog.Warn().Err(err).Msg("Failed to get last app state key ID to send hacky phone ping - sending empty request") - } - resp, err := user.Client.SendMessage(ctx, user.JID.ToNonAD(), &waProto.Message{ - ProtocolMessage: &waProto.ProtocolMessage{ - Type: waProto.ProtocolMessage_APP_STATE_SYNC_KEY_REQUEST.Enum(), - AppStateSyncKeyRequest: &waProto.AppStateSyncKeyRequest{ - KeyIDs: keyIDs, - }, - }, - }, whatsmeow.SendRequestExtra{Peer: true, ID: msgID}) - if err != nil { - user.zlog.Err(err).Msg("Failed to send hacky phone ping") - } else { - user.zlog.Debug(). - Str("message_id", msgID). - Int64("message_ts", resp.Timestamp.Unix()). - Msg("Sent hacky phone ping because phone has been offline for >10 days") - user.PhoneLastPinged = resp.Timestamp - err = user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after sending hacky phone ping") - } - } -} - -func (user *User) PhoneRecentlySeen(doPing bool) bool { - if doPing && !user.PhoneLastSeen.IsZero() && user.PhoneLastSeen.Add(PhoneDisconnectPingTime).Before(time.Now()) && user.PhoneLastPinged.Add(PhoneMinPingInterval).Before(time.Now()) { - // Over 10 days since the phone was seen and over a day since the last somewhat hacky ping, send a new ping. - go user.sendHackyPhonePing(context.TODO()) - } - return user.PhoneLastSeen.IsZero() || user.PhoneLastSeen.Add(PhoneDisconnectWarningTime).After(time.Now()) -} - -// phoneSeen records a timestamp when the user's main device was seen online. -// The stored timestamp can later be used to warn the user if the main device is offline for too long. -func (user *User) phoneSeen(ts time.Time) { - if user.PhoneLastSeen.Add(1 * time.Hour).After(ts) { - // The last seen timestamp isn't going to be perfectly accurate in any case, - // so don't spam the database with an update every time there's an event. - return - } else if !user.PhoneRecentlySeen(false) { - if user.BridgeState.GetPrev().Error == WAPhoneOffline && user.IsConnected() { - user.zlog.Debug().Msg("Saw phone after current bridge state said it has been offline, switching state back to connected") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - } else { - user.zlog.Debug(). - Bool("is_connected", user.IsConnected()). - Str("prev_error", string(user.BridgeState.GetPrev().Error)). - Msg("Saw phone after current bridge state said it has been offline, not sending new bridge state") - } - } - user.PhoneLastSeen = ts - go func() { - err := user.Update(context.TODO()) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after updating phone last seen") - } - }() -} - -func formatDisconnectTime(dur time.Duration) string { - days := int(math.Floor(dur.Hours() / 24)) - hours := int(dur.Hours()) % 24 - if hours == 0 { - return fmt.Sprintf("%d days", days) - } else if hours == 1 { - return fmt.Sprintf("%d days and 1 hour", days) - } else { - return fmt.Sprintf("%d days and %d hours", days, hours) - } -} - -func (user *User) sendPhoneOfflineWarning(ctx context.Context) { - if user.lastPhoneOfflineWarning.Add(12 * time.Hour).After(time.Now()) { - // Don't spam the warning too much - return - } - user.lastPhoneOfflineWarning = time.Now() - timeSinceSeen := time.Now().Sub(user.PhoneLastSeen) - user.sendMarkdownBridgeAlert(ctx, "Your phone hasn't been seen in %s. The server will force the bridge to log out if the phone is not active at least every 2 weeks.", formatDisconnectTime(timeSinceSeen)) -} - -func (user *User) HandleEvent(event interface{}) { - ctx := user.zlog.With(). - Str("action", "handle whatsapp event"). - Type("wa_event_type", event). - Logger(). - WithContext(context.TODO()) - switch v := event.(type) { - case *events.LoggedOut: - go user.handleLoggedOut(ctx, v.OnConnect, v.Reason) - case *events.Connected: - user.bridge.Metrics.TrackConnectionState(user.JID, true) - user.bridge.Metrics.TrackLoginState(user.JID, true) - if len(user.Client.Store.PushName) > 0 { - go func() { - err := user.Client.SendPresence(user.lastPresence) - if err != nil { - user.zlog.Warn().Err(err).Msg("Failed to send initial presence after connecting") - } - }() - } - go user.tryAutomaticDoublePuppeting() - - if user.bridge.Config.Bridge.HistorySync.Backfill && !user.historySyncLoopsStarted { - go user.handleHistorySyncsLoop() - user.historySyncLoopsStarted = true - } - case *events.OfflineSyncPreview: - user.zlog.Info(). - Int("message_count", v.Messages). - Int("receipt_count", v.Receipts). - Int("notification_count", v.Notifications). - Int("app_data_change_count", v.AppDataChanges). - Msg("Server sent number of events that were missed during downtime") - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateBackfilling, - Message: fmt.Sprintf("backfilling %d messages and %d receipts", v.Messages, v.Receipts), - }) - case *events.OfflineSyncCompleted: - if !user.PhoneRecentlySeen(true) { - user.zlog.Info(). - Time("phone_last_seen", user.PhoneLastSeen). - Msg("Offline sync completed, but phone last seen date is still old - sending phone offline bridge status") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAPhoneOffline}) - } else { - if user.BridgeState.GetPrev().StateEvent == status.StateBackfilling { - user.zlog.Info().Msg("Offline sync completed") - } - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - } - case *events.AppStateSyncComplete: - if len(user.Client.Store.PushName) > 0 && v.Name == appstate.WAPatchCriticalBlock { - err := user.Client.SendPresence(user.lastPresence) - if err != nil { - user.zlog.Warn().Err(err).Msg("Failed to send presence after app state sync") - } - } else if v.Name == appstate.WAPatchCriticalUnblockLow { - go func() { - err := user.ResyncContacts(false) - if err != nil { - user.zlog.Err(err).Msg("Failed to resync contacts after app state sync") - } - }() - } - case *events.PushNameSetting: - // Send presence available when connecting and when the pushname is changed. - // This makes sure that outgoing messages always have the right pushname. - err := user.Client.SendPresence(user.lastPresence) - if err != nil { - user.zlog.Warn().Err(err).Msg("Failed to send presence after push name update") - } - _, _, err = user.Client.Store.Contacts.PutPushName(user.JID.ToNonAD(), v.Action.GetName()) - if err != nil { - user.zlog.Err(err).Msg("Failed to update push name in store") - } - go user.syncPuppet(user.JID.ToNonAD(), "push name setting") - case *events.PairSuccess: - user.PhoneLastSeen = time.Now() - user.Session = user.Client.Store - user.JID = v.ID - user.addToJIDMap() - err := user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after pair success") - } - case *events.StreamError: - var message string - if v.Code != "" { - message = fmt.Sprintf("Unknown stream error with code %s", v.Code) - } else if children := v.Raw.GetChildren(); len(children) > 0 { - message = fmt.Sprintf("Unknown stream error (contains %s node)", children[0].Tag) - } else { - message = "Unknown stream error" - } - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: message}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - case *events.StreamReplaced: - if user.bridge.Config.Bridge.CrashOnStreamReplaced { - user.zlog.Info().Msg("Stopping bridge due to StreamReplaced event") - user.bridge.ManualStop(60) - } else { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: "Stream replaced"}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - user.sendMarkdownBridgeAlert(ctx, "The bridge was started in another location. Use `reconnect` to reconnect this one.") - } - case *events.ConnectFailure: - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: fmt.Sprintf("Unknown connection failure: %s (%s)", v.Reason, v.Message)}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - user.bridge.Metrics.TrackConnectionFailure(fmt.Sprintf("status-%d", v.Reason)) - case *events.ClientOutdated: - user.zlog.Error().Msg("Got a client outdated connect failure. The bridge is likely out of date, please update immediately.") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: "Connect failure: 405 client outdated"}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - user.bridge.Metrics.TrackConnectionFailure("client-outdated") - case *events.TemporaryBan: - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: v.String()}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - user.bridge.Metrics.TrackConnectionFailure("temporary-ban") - case *events.Disconnected: - // Don't send the normal transient disconnect state if we're already in a different transient disconnect state. - // TODO remove this if/when the phone offline state is moved to a sub-state of CONNECTED - if user.BridgeState.GetPrev().Error != WAPhoneOffline && user.PhoneRecentlySeen(false) { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WADisconnected}) - } - user.bridge.Metrics.TrackConnectionState(user.JID, false) - case *events.Contact: - go user.syncPuppet(v.JID, "contact event") - case *events.PushName: - go user.syncPuppet(v.JID, "push name event") - case *events.BusinessName: - go user.syncPuppet(v.JID, "business name event") - case *events.GroupInfo: - user.groupListCache = nil - go user.handleGroupUpdate(v) - case *events.JoinedGroup: - user.groupListCache = nil - go user.handleGroupCreate(v) - case *events.NewsletterJoin: - go user.handleNewsletterJoin(v) - case *events.NewsletterLeave: - go user.handleNewsletterLeave(v) - case *events.Picture: - go user.handlePictureUpdate(ctx, v) - case *events.Receipt: - if v.IsFromMe && v.Sender.Device == 0 { - user.phoneSeen(v.Timestamp) - } - go user.handleReceipt(v) - case *events.ChatPresence: - go user.handleChatPresence(ctx, v) - case *events.Message: - portal := user.GetPortalByMessageSource(v.Info.MessageSource) - portal.events <- &PortalEvent{ - Message: &PortalMessage{evt: v, source: user}, - } - case *events.MediaRetry: - user.phoneSeen(v.Timestamp) - portal := user.GetPortalByJID(v.ChatID) - go portal.handleMediaRetry(v, user) - case *events.CallOffer: - user.handleCallStart(v.CallCreator, v.CallID, "", v.Timestamp) - case *events.CallOfferNotice: - user.handleCallStart(v.CallCreator, v.CallID, v.Type, v.Timestamp) - case *events.IdentityChange: - puppet := user.bridge.GetPuppetByJID(v.JID) - if puppet == nil { - return - } - portal := user.GetPortalByJID(v.JID) - if len(portal.MXID) > 0 && user.bridge.Config.Bridge.IdentityChangeNotices { - text := fmt.Sprintf("Your security code with %s changed.", puppet.Displayname) - if v.Implicit { - text = fmt.Sprintf("Your security code with %s (device #%d) changed.", puppet.Displayname, v.JID.Device) - } - portal.events <- &PortalEvent{ - Message: &PortalMessage{ - fake: &fakeMessage{ - Sender: v.JID, - Text: text, - ID: strconv.FormatInt(v.Timestamp.Unix(), 10), - Time: v.Timestamp, - Important: false, - }, - source: user, - }, - } - } - case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent: - // ignore - case *events.UndecryptableMessage: - portal := user.GetPortalByMessageSource(v.Info.MessageSource) - portal.events <- &PortalEvent{ - Message: &PortalMessage{undecryptable: v, source: user}, - } - case *events.HistorySync: - if user.bridge.Config.Bridge.HistorySync.Backfill { - user.historySyncs <- v - } - case *events.Mute: - portal := user.GetPortalByJID(v.JID) - if portal != nil { - var mutedUntil time.Time - if v.Action.GetMuted() { - mutedUntil = time.Unix(v.Action.GetMuteEndTimestamp(), 0) - } - go user.updateChatMute(ctx, nil, portal, mutedUntil) - } - case *events.Archive: - portal := user.GetPortalByJID(v.JID) - if portal != nil { - go user.updateChatTag(ctx, nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.Action.GetArchived()) - } - case *events.Pin: - portal := user.GetPortalByJID(v.JID) - if portal != nil { - go user.updateChatTag(ctx, nil, portal, user.bridge.Config.Bridge.PinnedTag, v.Action.GetPinned()) - } - case *events.AppState: - // Ignore - case *events.KeepAliveTimeout: - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAKeepaliveTimeout}) - case *events.KeepAliveRestored: - user.zlog.Info().Msg("Keepalive restored after timeouts, sending connected event") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - case *events.MarkChatAsRead: - if user.bridge.Config.Bridge.SyncManualMarkedUnread { - user.markUnread(ctx, user.GetPortalByJID(v.JID), !v.Action.GetRead()) - } - case *events.DeleteForMe: - portal := user.GetPortalByJID(v.ChatJID) - if portal != nil { - portal.deleteForMe(ctx, user, v) - } - case *events.DeleteChat: - portal := user.GetPortalByJID(v.JID) - if portal != nil { - portal.HandleWhatsAppDeleteChat(ctx, user) - } - case *events.QR: - if user.qrWaiting.Swap(false) { - close(user.qrReceived) - } - default: - user.zlog.Debug().Type("event_type", v).Msg("Unknown type of event in HandleEvent") - } -} - -func (user *User) updateChatMute(ctx context.Context, intent *appservice.IntentAPI, portal *Portal, mutedUntil time.Time) { - if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteBridging { - return - } else if intent == nil { - doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet == nil || doublePuppet.CustomIntent() == nil { - return - } - intent = doublePuppet.CustomIntent() - } - var err error - if mutedUntil.IsZero() && mutedUntil.Before(time.Now()) { - user.zlog.Debug(). - Stringer("portal_mxid", portal.MXID). - Time("muted_until", mutedUntil). - Msg("Portal muted until time is in the past, unmuting") - err = intent.DeletePushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID)) - } else { - user.zlog.Debug(). - Stringer("portal_mxid", portal.MXID). - Time("muted_until", mutedUntil). - Msg("Portal muted until time is in the future, muting") - err = intent.PutPushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ - Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, - }) - } - if err != nil && !errors.Is(err, mautrix.MNotFound) { - user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to update push rule through double puppet") - } -} - -type CustomTagData struct { - Order json.Number `json:"order"` - DoublePuppet string `json:"fi.mau.double_puppet_source"` -} - -type CustomTagEventContent struct { - Tags map[event.RoomTag]CustomTagData `json:"tags"` -} - -func (user *User) updateChatTag(ctx context.Context, intent *appservice.IntentAPI, portal *Portal, tag event.RoomTag, active bool) { - if len(portal.MXID) == 0 || len(tag) == 0 { - return - } else if intent == nil { - doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet == nil || doublePuppet.CustomIntent() == nil { - return - } - intent = doublePuppet.CustomIntent() - } - var existingTags CustomTagEventContent - err := intent.GetTagsWithCustomData(ctx, portal.MXID, &existingTags) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to get tags through double puppet") - } - currentTag, ok := existingTags.Tags[tag] - if active && !ok { - user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Adding tag to portal") - data := CustomTagData{Order: "0.5", DoublePuppet: user.bridge.Name} - err = intent.AddTagWithCustomData(ctx, portal.MXID, tag, &data) - } else if !active && ok && currentTag.DoublePuppet == user.bridge.Name { - user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Removing tag from portal") - err = intent.RemoveTag(ctx, portal.MXID, tag) - } else { - err = nil - } - if err != nil { - user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Failed to update tag through double puppet") - } -} - -type CustomReadReceipt struct { - Timestamp int64 `json:"ts,omitempty"` - DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` -} - -type CustomReadMarkers struct { - mautrix.ReqSetReadMarkers - ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"` - FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"` -} - -func (user *User) syncChatDoublePuppetDetails(ctx context.Context, portal *Portal, justCreated bool) { - doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet == nil { - return - } - if doublePuppet == nil || doublePuppet.CustomIntent() == nil || len(portal.MXID) == 0 { - return - } - if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate { - chat, err := user.Client.Store.ChatSettings.GetChatSettings(portal.Key.JID) - if err != nil { - user.zlog.Err(err).Stringer("portal_jid", portal.Key.JID).Msg("Failed to get chat settings from store") - return - } - intent := doublePuppet.CustomIntent() - if portal.Key.JID == types.StatusBroadcastJID && justCreated { - if user.bridge.Config.Bridge.MuteStatusBroadcast { - user.updateChatMute(ctx, intent, portal, time.Now().Add(365*24*time.Hour)) - } - if len(user.bridge.Config.Bridge.StatusBroadcastTag) > 0 { - user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.StatusBroadcastTag, true) - } - return - } else if !chat.Found { - return - } - user.updateChatMute(ctx, intent, portal, chat.MutedUntil) - user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.ArchiveTag, chat.Archived) - user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned) - } -} - -func (user *User) getDirectChats(ctx context.Context) map[id.UserID][]id.RoomID { - res := make(map[id.UserID][]id.RoomID) - privateChats, err := user.bridge.DB.Portal.FindPrivateChats(ctx, user.JID.ToNonAD()) - if err != nil { - user.zlog.Err(err).Msg("Failed to get private chats of user") - return res - } - for _, portal := range privateChats { - if len(portal.MXID) > 0 { - res[user.bridge.FormatPuppetMXID(portal.Key.JID)] = []id.RoomID{portal.MXID} - } - } - return res -} - -func (user *User) UpdateDirectChats(ctx context.Context, chats map[id.UserID][]id.RoomID) { - if !user.bridge.Config.Bridge.SyncDirectChatList { - return - } - puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.CustomIntent() == nil { - return - } - intent := puppet.CustomIntent() - method := http.MethodPatch - if chats == nil { - chats = user.getDirectChats(ctx) - method = http.MethodPut - } - user.zlog.Debug().Msg("Updating m.direct list on homeserver") - var err error - if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux { - urlPath := intent.BuildClientURL("unstable", "com.beeper.asmux", "dms") - _, err = intent.MakeFullRequest(ctx, mautrix.FullRequest{ - Method: method, - URL: urlPath, - Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}}, - RequestJSON: chats, - }) - } else { - existingChats := make(map[id.UserID][]id.RoomID) - err = intent.GetAccountData(ctx, event.AccountDataDirectChats.Type, &existingChats) - if err != nil { - user.zlog.Err(err).Msg("Failed to get m.direct list to update it") - return - } - for userID, rooms := range existingChats { - if _, ok := user.bridge.ParsePuppetMXID(userID); !ok { - // This is not a ghost user, include it in the new list - chats[userID] = rooms - } else if _, ok := chats[userID]; !ok && method == http.MethodPatch { - // This is a ghost user, but we're not replacing the whole list, so include it too - chats[userID] = rooms - } - } - err = intent.SetAccountData(ctx, event.AccountDataDirectChats.Type, &chats) - } - if err != nil { - user.zlog.Err(err).Msg("Failed to update m.direct list") - } -} - -func (user *User) handleLoggedOut(ctx context.Context, onConnect bool, reason events.ConnectFailureReason) { - errorCode := WAUnknownLogout - if reason == events.ConnectFailureLoggedOut { - errorCode = WALoggedOut - } else if reason == events.ConnectFailureMainDeviceGone { - errorCode = WAMainDeviceGone - } - user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateBadCredentials, Error: errorCode}) - user.DeleteConnection() - user.Session = nil - user.JID = types.EmptyJID - err := user.Update(ctx) - if err != nil { - user.zlog.Err(err).Msg("Failed to save user after getting logged out") - } - if onConnect { - user.sendMarkdownBridgeAlert(ctx, "Connecting to WhatsApp failed as the device was unlinked (error %s). Please link the bridge to your phone again.", reason) - } else { - user.sendMarkdownBridgeAlert(ctx, "You were logged out from another device. Please link the bridge to your phone again.") - } -} - -func (user *User) GetPortalByMessageSource(ms types.MessageSource) *Portal { - jid := ms.Chat - if ms.IsIncomingBroadcast() { - if ms.IsFromMe { - jid = ms.BroadcastListOwner.ToNonAD() - } else { - jid = ms.Sender.ToNonAD() - } - if jid.IsEmpty() { - return nil - } - } - return user.bridge.GetPortalByJID(database.NewPortalKey(jid, user.JID)) -} - -func (user *User) GetPortalByJID(jid types.JID) *Portal { - return user.bridge.GetPortalByJID(database.NewPortalKey(jid, user.JID)) -} - -func (user *User) syncPuppet(jid types.JID, reason string) { - user.bridge.GetPuppetByJID(jid).SyncContact(user.zlog.WithContext(context.TODO()), user, false, false, reason) -} - -func (user *User) ResyncContacts(forceAvatarSync bool) error { - contacts, err := user.Client.Store.Contacts.GetAllContacts() - if err != nil { - return fmt.Errorf("failed to get cached contacts: %w", err) - } - user.zlog.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info") - ctx := user.zlog.With().Str("action", "resync contacts").Logger().WithContext(context.TODO()) - for jid, contact := range contacts { - puppet := user.bridge.GetPuppetByJID(jid) - if puppet != nil { - puppet.Sync(ctx, user, &contact, forceAvatarSync, true) - } else { - user.zlog.Warn().Stringer("jid", jid).Msg("Got a nil puppet while syncing contacts") - } - } - return nil -} - -func (user *User) ResyncGroups(createPortals bool) error { - groups, err := user.Client.GetJoinedGroups() - if err != nil { - return fmt.Errorf("failed to get group list from server: %w", err) - } - user.groupListCacheLock.Lock() - user.groupListCache = groups - user.groupListCacheTime = time.Now() - user.groupListCacheLock.Unlock() - ctx := user.zlog.With().Str("method", "ResyncGroups").Logger().WithContext(context.TODO()) - for _, group := range groups { - portal := user.GetPortalByJID(group.JID) - if len(portal.MXID) == 0 { - if createPortals { - err = portal.CreateMatrixRoom(ctx, user, group, nil, true, true) - if err != nil { - return fmt.Errorf("failed to create room for %s: %w", group.JID, err) - } - } - } else { - portal.UpdateMatrixRoom(ctx, user, group, nil) - } - } - return nil -} - -const WATypingTimeout = 15 * time.Second - -func (user *User) handleChatPresence(ctx context.Context, presence *events.ChatPresence) { - puppet := user.bridge.GetPuppetByJID(presence.Sender) - if puppet == nil { - return - } - portal := user.GetPortalByJID(presence.Chat) - if puppet == nil || portal == nil || len(portal.MXID) == 0 { - return - } - if presence.State == types.ChatPresenceComposing { - if puppet.typingIn != "" && puppet.typingAt.Add(WATypingTimeout).Before(time.Now()) { - if puppet.typingIn == portal.MXID { - return - } - _, _ = puppet.IntentFor(portal).UserTyping(ctx, puppet.typingIn, false, 0) - } - _, _ = puppet.IntentFor(portal).UserTyping(ctx, portal.MXID, true, WATypingTimeout) - puppet.typingIn = portal.MXID - puppet.typingAt = time.Now() - } else { - _, _ = puppet.IntentFor(portal).UserTyping(ctx, portal.MXID, false, 0) - puppet.typingIn = "" - } -} - -func (user *User) handleReceipt(receipt *events.Receipt) { - if receipt.Type != types.ReceiptTypeRead && receipt.Type != types.ReceiptTypeReadSelf && receipt.Type != types.ReceiptTypeDelivered { - return - } - portal := user.GetPortalByMessageSource(receipt.MessageSource) - if portal == nil || len(portal.MXID) == 0 { - return - } - portal.events <- &PortalEvent{ - Message: &PortalMessage{receipt: receipt, source: user}, - } -} - -func (user *User) makeReadMarkerContent(eventID id.EventID, doublePuppet bool) CustomReadMarkers { - var extra CustomReadReceipt - if doublePuppet { - extra.DoublePuppetSource = user.bridge.Name - } - return CustomReadMarkers{ - ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ - Read: eventID, - FullyRead: eventID, - }, - ReadExtra: extra, - FullyReadExtra: extra, - } -} - -func (user *User) markSelfReadFull(ctx context.Context, portal *Portal) { - puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.CustomIntent() == nil { - return - } - lastMessage, err := user.bridge.DB.Message.GetLastInChat(ctx, portal.Key) - if err != nil { - user.zlog.Err(err).Msg("Failed to get last message in chat to mark as read") - return - } else if lastMessage == nil { - return - } - user.SetLastReadTS(ctx, portal.Key, lastMessage.Timestamp) - err = puppet.CustomIntent().SetReadMarkers(ctx, portal.MXID, user.makeReadMarkerContent(lastMessage.MXID, true)) - if err != nil { - user.zlog.Err(err). - Stringer("portal_mxid", portal.MXID). - Stringer("last_message_mxid", lastMessage.MXID). - Msg("Failed to mark last message in chat as read") - } else { - user.zlog.Debug(). - Stringer("portal_mxid", portal.MXID). - Stringer("last_message_mxid", lastMessage.MXID). - Msg("Marked last message in chat as read") - } -} - -func (user *User) markUnread(ctx context.Context, portal *Portal, unread bool) { - puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.CustomIntent() == nil { - return - } - - err := puppet.CustomIntent().SetRoomAccountData(ctx, portal.MXID, "m.marked_unread", - map[string]bool{"unread": unread}) - if err != nil { - user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to mark room as unread (m.marked_unread)") - } else { - user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Marked room as unread (m.marked_unread)") - } - - err = puppet.CustomIntent().SetRoomAccountData(ctx, portal.MXID, "com.famedly.marked_unread", - map[string]bool{"unread": unread}) - if err != nil { - user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to mark room as unread (com.famedly.marked_unread)") - } else { - user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Marked room as unread (com.famedly.marked_unread)") - } -} - -func (user *User) handleGroupCreate(evt *events.JoinedGroup) { - log := user.zlog.With().Str("whatsapp_event", "JoinedGroup").Logger() - ctx := log.WithContext(context.TODO()) - portal := user.GetPortalByJID(evt.JID) - if evt.CreateKey == "" && len(portal.MXID) == 0 && portal.Key.JID != user.skipGroupCreateDelay { - log.Debug().Msg("Delaying handling group create with empty key to avoid race conditions") - time.Sleep(5 * time.Second) - } - if len(portal.MXID) == 0 { - if user.createKeyDedup != "" && evt.CreateKey == user.createKeyDedup { - log.Debug().Str("create_key", evt.CreateKey).Msg("Ignoring group create event with cached create key") - return - } - err := portal.CreateMatrixRoom(ctx, user, &evt.GroupInfo, nil, true, true) - if err != nil { - log.Err(err).Msg("Failed to create Matrix room after join notification") - } - } else { - portal.UpdateMatrixRoom(ctx, user, &evt.GroupInfo, nil) - } -} - -func (user *User) handleGroupUpdate(evt *events.GroupInfo) { - portal := user.GetPortalByJID(evt.JID) - with := user.zlog.With(). - Str("chat_jid", evt.JID.String()). - Interface("group_event", evt) - if portal != nil { - with = with.Str("portal_mxid", portal.MXID.String()) - } - log := with.Logger() - if portal == nil || len(portal.MXID) == 0 { - log.Debug().Msg("Ignoring group info update in chat with no portal") - return - } - if evt.Sender != nil && evt.Sender.Server == types.HiddenUserServer { - log.Debug().Str("sender", evt.Sender.String()).Msg("Ignoring group info update from @lid user") - return - } - ctx := log.WithContext(context.TODO()) - switch { - case evt.Announce != nil: - log.Debug().Msg("Group announcement mode (message send permission) changed") - portal.RestrictMessageSending(ctx, evt.Announce.IsAnnounce) - case evt.Locked != nil: - log.Debug().Msg("Group locked mode (metadata change permission) changed") - portal.RestrictMetadataChanges(ctx, evt.Locked.IsLocked) - case evt.Name != nil: - log.Debug().Msg("Group name changed") - portal.UpdateName(ctx, evt.Name.Name, evt.Name.NameSetBy, true) - case evt.Topic != nil: - log.Debug().Msg("Group topic changed") - portal.UpdateTopic(ctx, evt.Topic.Topic, evt.Topic.TopicSetBy, true) - case evt.Leave != nil: - log.Debug().Msg("Someone left the group") - if evt.Sender != nil && !evt.Sender.IsEmpty() { - portal.HandleWhatsAppKick(ctx, user, *evt.Sender, evt.Leave) - } - case evt.Join != nil: - log.Debug().Msg("Someone joined the group") - portal.HandleWhatsAppInvite(ctx, user, evt.Sender, evt.Join) - case evt.Promote != nil: - log.Debug().Msg("Someone was promoted to admin") - portal.ChangeAdminStatus(ctx, evt.Promote, true) - case evt.Demote != nil: - log.Debug().Msg("Someone was demoted from admin") - portal.ChangeAdminStatus(ctx, evt.Demote, false) - case evt.Ephemeral != nil: - log.Debug().Msg("Group ephemeral mode (disappearing message timer) changed") - portal.UpdateGroupDisappearingMessages(ctx, evt.Sender, evt.Timestamp, evt.Ephemeral.DisappearingTimer) - case evt.Link != nil: - log.Debug().Msg("Group parent changed") - if evt.Link.Type == types.GroupLinkChangeTypeParent { - portal.UpdateParentGroup(ctx, user, evt.Link.Group.JID, true) - } - case evt.Unlink != nil: - log.Debug().Msg("Group parent removed") - if evt.Unlink.Type == types.GroupLinkChangeTypeParent && portal.ParentGroup == evt.Unlink.Group.JID { - portal.UpdateParentGroup(ctx, user, types.EmptyJID, true) - } - case evt.Delete != nil: - log.Debug().Msg("Group deleted") - portal.Delete(ctx) - portal.Cleanup(ctx, false) - default: - log.Warn().Msg("Unhandled group info update") - } -} - -func (user *User) handleNewsletterJoin(evt *events.NewsletterJoin) { - ctx := user.zlog.With().Str("whatsapp_event", "NewsletterJoin").Logger().WithContext(context.TODO()) - portal := user.GetPortalByJID(evt.ID) - if portal.MXID == "" { - err := portal.CreateMatrixRoom(ctx, user, nil, &evt.NewsletterMetadata, true, false) - if err != nil { - user.zlog.Err(err).Msg("Failed to create room on newsletter join event") - } - } else { - portal.UpdateMatrixRoom(ctx, user, nil, &evt.NewsletterMetadata) - } -} - -func (user *User) handleNewsletterLeave(evt *events.NewsletterLeave) { - ctx := user.zlog.With().Str("whatsapp_event", "NewsletterLeave").Logger().WithContext(context.TODO()) - portal := user.GetPortalByJID(evt.ID) - if portal.MXID != "" { - portal.HandleWhatsAppKick(ctx, user, user.JID, []types.JID{user.JID}) - } -} - -func (user *User) handlePictureUpdate(ctx context.Context, evt *events.Picture) { - if evt.JID.Server == types.DefaultUserServer { - puppet := user.bridge.GetPuppetByJID(evt.JID) - user.zlog.Debug(). - Stringer("jid", evt.JID). - Str("current_avatar", puppet.Avatar). - Str("new_avatar", evt.PictureID). - Msg("Received picture update for puppet") - if puppet.Avatar != evt.PictureID { - puppet.Sync(ctx, user, nil, true, false) - } - } else if portal := user.GetPortalByJID(evt.JID); portal != nil { - user.zlog.Debug(). - Stringer("jid", evt.JID). - Str("current_avatar", portal.Avatar). - Str("new_avatar", evt.PictureID). - Msg("Received picture update for portal") - if portal.Avatar != evt.PictureID { - portal.UpdateAvatar(ctx, user, evt.Author, true) - } - } -} - -func (user *User) StartPM(ctx context.Context, jid types.JID, reason string) (*Portal, *Puppet, bool, error) { - zerolog.Ctx(ctx).Debug().Stringer("jid", jid).Str("source", reason).Msg("Starting PM with user") - puppet := user.bridge.GetPuppetByJID(jid) - puppet.SyncContact(ctx, user, true, false, reason) - portal := user.GetPortalByJID(puppet.JID) - if len(portal.MXID) > 0 { - ok := portal.ensureUserInvited(ctx, user) - if !ok { - zerolog.Ctx(ctx).Warn().Msg("Failed to ensure user is invited to room in StartPM, creating new portal") - portal.MXID = "" - portal.updateLogger() - } else { - return portal, puppet, false, nil - } - } - err := portal.CreateMatrixRoom(ctx, user, nil, nil, false, true) - return portal, puppet, true, err -} - -const groupListCacheMaxAge = 24 * time.Hour - -func (user *User) getCachedGroupList() ([]*types.GroupInfo, error) { - user.groupListCacheLock.Lock() - defer user.groupListCacheLock.Unlock() - if user.groupListCache != nil && user.groupListCacheTime.Add(groupListCacheMaxAge).After(time.Now()) { - return user.groupListCache, nil - } - var err error - user.groupListCache, err = user.Client.GetJoinedGroups() - user.groupListCacheTime = time.Now() - return user.groupListCache, err -}