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 -}