diff --git a/.gitignore b/.gitignore index 0031287..ab00deb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,5 @@ *.log* /mautrix-signal -/mautrix-signalgo -/mautrix-signal-v2 /start /libsignal_ffi.a diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 03bd8f5..952dabc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,17 +1,12 @@ include: -- project: 'mautrix/ci' - file: '/go.yml' - project: 'mautrix/ci' file: '/gov2.yml' variables: BUILDER_IMAGE: dock.mau.dev/tulir/gomuks-build-docker/signal + BINARY_NAME_V2: mautrix-signal # 32-bit arm builds aren't supported -build arm: - rules: - - when: never - build arm v2: rules: - when: never diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b22a061..8a667b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace exclude_types: [markdown] @@ -18,11 +18,11 @@ repos: - "go.mau.fi/mautrix-signal" - "-w" - id: go-vet-mod - #- id: go-staticcheck-repo-mod +# - id: go-staticcheck-repo-mod # TODO: reenable this and fix all the problems - repo: https://github.com/beeper/pre-commit-go - rev: v0.3.0 + rev: v0.3.1 hooks: - id: zerolog-ban-msgf - id: zerolog-use-stringer diff --git a/CHANGELOG.md b/CHANGELOG.md index f00f374..f0c11f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v0.7.0 (unreleased) + +* Updated to libsignal v0.54.0. +* Rewrote bridge using bridgev2 architecture. + * It is recommended to check the config file after upgrading. + # v0.6.3 (2024-07-16) * Updated to libsignal v0.52.0. diff --git a/Dockerfile.ci b/Dockerfile.ci deleted file mode 100644 index e75845e..0000000 --- a/Dockerfile.ci +++ /dev/null @@ -1,14 +0,0 @@ -FROM alpine:3.19 - -ENV UID=1337 \ - GID=1337 - -RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq - -ARG EXECUTABLE=./mautrix-signal -COPY $EXECUTABLE /usr/bin/mautrix-signal -COPY ./example-config.yaml /opt/mautrix-signal/example-config.yaml -COPY ./docker-run.sh /docker-run.sh -VOLUME /data - -CMD ["/docker-run.sh"] diff --git a/Makefile b/Makefile deleted file mode 100644 index b497635..0000000 --- a/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -.PHONY: all build_rust copy_library build_go clean - -all: build_rust copy_library build_go - -LIBRARY_FILENAME=libsignal_ffi.a -RUST_DIR=pkg/libsignalgo/libsignal -GO_BINARY=mautrix-signal - -# TODO fix linking with debug library -#ifneq ($(DBG),1) -RUST_TARGET_SUBDIR=release -#else -#RUST_TARGET_SUBDIR=debug -#endif - -build_rust: - ./build-rust.sh - -copy_library: - cp $(RUST_DIR)/target/$(RUST_TARGET_SUBDIR)/$(LIBRARY_FILENAME) . - -build_go: - LIBRARY_PATH="$${LIBRARY_PATH}:." ./build-go.sh - -clean: - rm -f ./$(LIBRARY_FILENAME) - cd $(RUST_DIR) && cargo clean - rm -f $(GO_BINARY) diff --git a/ROADMAP.md b/ROADMAP.md index 85c7494..9c0cedb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,17 +1,17 @@ # Features & roadmap * Matrix → Signal - * [ ] Message content + * [x] Message content * [x] Text * [x] Formatting * [x] Mentions - * [ ] Media + * [x] Media * [x] Images * [x] Audio files * [x] Voice messages * [x] Files * [x] Gifs - * [ ] Locations + * [x] Locations * [x] Stickers * [x] Message edits * [x] Message reactions @@ -22,9 +22,9 @@ * [x] Topic * [ ] Membership actions * [ ] Join (accepting invites) - * [x] Invite - * [x] Leave - * [x] Kick/Ban/Unban + * [ ] Invite + * [ ] Leave + * [ ] Kick/Ban/Unban * [x] Group permissions * [x] Typing notifications * [x] Read receipts @@ -70,5 +70,5 @@ * [x] When receiving message * [x] Linking as secondary device * [ ] Registering as primary device - * [x] Private chat/group creation by inviting Matrix puppet of Signal user to new room + * [ ] Private chat/group creation by inviting Matrix puppet of Signal user to new room * [x] Option to use own Matrix account for messages sent from other Signal clients diff --git a/build-go-v2.sh b/build-go-v2.sh deleted file mode 100755 index 199f474..0000000 --- a/build-go-v2.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') -GO_LDFLAGS="-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'" -if [ "$DBG" = 1 ]; then - GO_GCFLAGS='all=-N -l' -else - GO_LDFLAGS="-s -w ${GO_LDFLAGS}" -fi -go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal-v2 ./cmd/mautrix-signal-v2 "$@" diff --git a/build-go.sh b/build-go.sh index ecf8907..faaac6f 100755 --- a/build-go.sh +++ b/build-go.sh @@ -1,9 +1,9 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') -GO_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'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +GO_LDFLAGS="-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'" if [ "$DBG" = 1 ]; then GO_GCFLAGS='all=-N -l' else GO_LDFLAGS="-s -w ${GO_LDFLAGS}" fi -go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal "$@" +go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal ./cmd/mautrix-signal "$@" diff --git a/build-v2.sh b/build-v2.sh index b541932..2b51084 100755 --- a/build-v2.sh +++ b/build-v2.sh @@ -1,4 +1,4 @@ #!/bin/sh -#./build-rust.sh -#cp -f pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a . -LIBRARY_PATH=.:$LIBRARY_PATH ./build-go-v2.sh +./build-rust.sh +cp -f pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a . +LIBRARY_PATH=.:$LIBRARY_PATH ./build-go.sh diff --git a/build.sh b/build.sh deleted file mode 100755 index b17ad4f..0000000 --- a/build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -git submodule init -git submodule update -make diff --git a/cmd/mautrix-signal-v2/legacymigrate.go b/cmd/mautrix-signal/legacymigrate.go similarity index 100% rename from cmd/mautrix-signal-v2/legacymigrate.go rename to cmd/mautrix-signal/legacymigrate.go diff --git a/cmd/mautrix-signal-v2/legacymigrate.sql b/cmd/mautrix-signal/legacymigrate.sql similarity index 100% rename from cmd/mautrix-signal-v2/legacymigrate.sql rename to cmd/mautrix-signal/legacymigrate.sql diff --git a/cmd/mautrix-signal-v2/legacyprovision.go b/cmd/mautrix-signal/legacyprovision.go similarity index 69% rename from cmd/mautrix-signal-v2/legacyprovision.go rename to cmd/mautrix-signal/legacyprovision.go index b3c3f80..8e8e752 100644 --- a/cmd/mautrix-signal-v2/legacyprovision.go +++ b/cmd/mautrix-signal/legacyprovision.go @@ -28,8 +28,8 @@ import ( "github.com/rs/zerolog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-signal/legacyprovision" "go.mau.fi/mautrix-signal/pkg/connector" ) @@ -54,7 +54,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) { user := m.Matrix.Provisioning.GetUser(r) defLogin := user.GetDefaultLogin() if defLogin != nil && defLogin.Client != nil && defLogin.Client.IsLoggedIn() { - legacyprovision.JSONResponse(w, http.StatusConflict, &legacyprovision.Error{ + JSONResponse(w, http.StatusConflict, &Error{ Error: "Already logged in", ErrCode: "FI.MAU.ALREADY_LOGGED_IN", }) @@ -64,7 +64,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) { login, err := m.Connector.CreateLogin(r.Context(), user, "qr") if err != nil { log.Err(err).Msg("Failed to create login") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, &Error{ Error: "Internal error starting login", ErrCode: "M_UNKNOWN", }) @@ -73,14 +73,14 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) { firstStep, err := login.Start(r.Context()) if err != nil { log.Err(err).Msg("Failed to start login") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, &Error{ Error: "Internal error starting login", ErrCode: "M_UNKNOWN", }) return } else if firstStep.StepID != connector.LoginStepQR || firstStep.Type != bridgev2.LoginStepTypeDisplayAndWait || firstStep.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeQR { log.Error().Any("first_step", firstStep).Msg("Unexpected first step") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, &Error{ Error: "Unexpected first login step", ErrCode: "M_UNKNOWN", }) @@ -93,7 +93,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) { User: user, } loginSessionsLock.Unlock() - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ + JSONResponse(w, http.StatusOK, Response{ Success: true, Status: "provisioning_url_received", SessionID: strconv.Itoa(int(handleID)), @@ -102,10 +102,10 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) { } func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess { - var body legacyprovision.LinkWaitForAccountRequest + var body LinkWaitForAccountRequest err := json.NewDecoder(r.Body).Decode(&body) if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ + JSONResponse(w, http.StatusBadRequest, Error{ Success: false, Error: "Error decoding JSON body", ErrCode: mautrix.MBadJSON.ErrCode, @@ -114,7 +114,7 @@ func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess } sessionID, err := strconv.Atoi(body.SessionID) if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ + JSONResponse(w, http.StatusBadRequest, Error{ Success: false, Error: "Error decoding session ID in JSON body", ErrCode: mautrix.MBadJSON.ErrCode, @@ -124,7 +124,7 @@ func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess process, ok := loginSessions[uint32(sessionID)] user := m.Matrix.Provisioning.GetUser(r) if !ok || process.User != user { - legacyprovision.JSONResponse(w, http.StatusNotFound, legacyprovision.Error{ + JSONResponse(w, http.StatusNotFound, Error{ Success: false, Error: "No session found", ErrCode: mautrix.MNotFound.ErrCode, @@ -142,7 +142,7 @@ func legacyProvLinkWaitScan(w http.ResponseWriter, r *http.Request) { res, err := login.Login.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context()) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to log in") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, Error{ Error: "Failed to log in", ErrCode: "M_UNKNOWN", }) @@ -150,14 +150,14 @@ func legacyProvLinkWaitScan(w http.ResponseWriter, r *http.Request) { return } else if res.StepID != connector.LoginStepProcess { zerolog.Ctx(r.Context()).Error().Any("first_step", res).Msg("Unexpected login step") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, Error{ Error: "Unexpected login step", ErrCode: "M_UNKNOWN", }) login.Delete() return } - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ + JSONResponse(w, http.StatusOK, Response{ Success: true, Status: "provisioning_data_received", }) @@ -171,18 +171,18 @@ func legacyProvLinkWaitAccount(w http.ResponseWriter, r *http.Request) { res, err := login.Login.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context()) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to log in") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, Error{ Error: "Failed to log in", ErrCode: "M_UNKNOWN", }) } else if res.StepID != connector.LoginStepComplete || res.Type != bridgev2.LoginStepTypeComplete { zerolog.Ctx(r.Context()).Error().Any("first_step", res).Msg("Unexpected login step") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, Error{ Error: "Unexpected login step", ErrCode: "M_UNKNOWN", }) } else { - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ + JSONResponse(w, http.StatusOK, Response{ Success: true, Status: "prekeys_registered", UUID: string(res.CompleteParams.UserLogin.ID), @@ -194,7 +194,7 @@ func legacyProvLinkWaitAccount(w http.ResponseWriter, r *http.Request) { func legacyProvLogout(w http.ResponseWriter, r *http.Request) { // No-op for backwards compatibility - legacyprovision.JSONResponse(w, http.StatusOK, nil) + JSONResponse(w, http.StatusOK, nil) } func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) { @@ -206,21 +206,21 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, resp, err := api.ResolveIdentifier(r.Context(), mux.Vars(r)["phonenum"], create) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{ + JSONResponse(w, http.StatusInternalServerError, &Error{ Error: fmt.Sprintf("Failed to resolve identifier: %v", err), ErrCode: "M_UNKNOWN", }) return } else if resp == nil { - legacyprovision.JSONResponse(w, http.StatusNotFound, &legacyprovision.Error{ + JSONResponse(w, http.StatusNotFound, &Error{ ErrCode: mautrix.MNotFound.ErrCode, Error: "User not found on Signal", }) return } status := http.StatusOK - apiResp := &legacyprovision.ResolveIdentifierResponse{ - ChatID: legacyprovision.ResolveIdentifierResponseChatID{ + apiResp := &ResolveIdentifierResponse{ + ChatID: ResolveIdentifierResponseChatID{ UUID: string(resp.UserID), Number: "", }, @@ -229,7 +229,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, if resp.UserInfo != nil { resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo) } - apiResp.OtherUser = &legacyprovision.ResolveIdentifierResponseOtherUser{ + apiResp.OtherUser = &ResolveIdentifierResponseOtherUser{ MXID: resp.Ghost.Intent.GetMXID(), DisplayName: resp.Ghost.Name, AvatarURL: resp.Ghost.AvatarMXC.ParseOrIgnore(), @@ -240,7 +240,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, resp.Chat.Portal, err = m.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{ + JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{ Err: "Failed to get portal", ErrCode: "M_UNKNOWN", }) @@ -253,7 +253,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo) if err != nil { zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{ + JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{ Err: "Failed to create portal room", ErrCode: "M_UNKNOWN", }) @@ -262,7 +262,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, } apiResp.RoomID = resp.Chat.Portal.MXID } - legacyprovision.JSONResponse(w, status, &legacyprovision.Response{ + JSONResponse(w, status, &Response{ Success: true, Status: "ok", ResolveIdentifierResponse: apiResp, @@ -276,3 +276,71 @@ func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) { func legacyProvPM(w http.ResponseWriter, r *http.Request) { legacyResolveIdentifierOrStartChat(w, r, true) } + +func JSONResponse(w http.ResponseWriter, status int, response any) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(response) +} + +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"` + + // For response in LinkNew + SessionID string `json:"session_id,omitempty"` + URI string `json:"uri,omitempty"` + + // For response in LinkWaitForAccount + UUID string `json:"uuid,omitempty"` + Number string `json:"number,omitempty"` + + // For response in ResolveIdentifier + *ResolveIdentifierResponse +} + +type WhoAmIResponse struct { + Permissions int `json:"permissions"` + MXID string `json:"mxid"` + Signal *WhoAmIResponseSignal `json:"signal,omitempty"` +} + +type WhoAmIResponseSignal struct { + Number string `json:"number"` + UUID string `json:"uuid"` + Name string `json:"name"` + Ok bool `json:"ok"` +} + +type ResolveIdentifierResponse struct { + RoomID id.RoomID `json:"room_id"` + ChatID ResolveIdentifierResponseChatID `json:"chat_id"` + JustCreated bool `json:"just_created"` + OtherUser *ResolveIdentifierResponseOtherUser `json:"other_user,omitempty"` +} + +type ResolveIdentifierResponseChatID struct { + UUID string `json:"uuid"` + Number string `json:"number"` +} + +type ResolveIdentifierResponseOtherUser struct { + MXID id.UserID `json:"mxid"` + DisplayName string `json:"displayname"` + AvatarURL id.ContentURI `json:"avatar_url"` +} + +type LinkWaitForScanRequest struct { + SessionID string `json:"session_id"` +} + +type LinkWaitForAccountRequest struct { + SessionID string `json:"session_id"` + DeviceName string `json:"device_name"` // TODO this seems to not be used anywhere +} diff --git a/cmd/mautrix-signal-v2/main.go b/cmd/mautrix-signal/main.go similarity index 100% rename from cmd/mautrix-signal-v2/main.go rename to cmd/mautrix-signal/main.go diff --git a/commands.go b/commands.go deleted file mode 100644 index 7600d66..0000000 --- a/commands.go +++ /dev/null @@ -1,1161 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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/hex" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/google/uuid" - "github.com/rs/zerolog" - "github.com/skip2/go-qrcode" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -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} -) - -type WrappedCommandEvent struct { - *commands.Event - Bridge *SignalBridge - User *User - Portal *Portal -} - -func (br *SignalBridge) RegisterCommands() { - proc := br.CommandProcessor.(*commands.Processor) - proc.AddHandlers( - cmdPing, - cmdLogin, - cmdSetDeviceName, - cmdPM, - cmdResolvePhone, - cmdSync, - cmdDeleteSession, - cmdSetRelay, - cmdUnsetRelay, - cmdDeletePortal, - cmdDeleteAllPortals, - cmdCleanupLostPortals, - cmdInviteLink, - cmdResetInviteLink, - cmdCreate, - cmdInvite, - cmdListInvited, - cmdRevokeInvite, - ) -} - -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.(*SignalBridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) - } -} - -var cmdSetRelay = &commands.FullHandler{ - Func: wrapCommand(fnSetRelay), - Name: "set-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Relay messages in this room through your Signal 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 - ce.Portal.Update(ce.Ctx) - ce.Reply("Messages from non-logged-in users in this room will now be bridged through your Signal 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 = "" - ce.Portal.Update(ce.Ctx) - ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") - } -} - -var cmdDeleteSession = &commands.FullHandler{ - Func: wrapCommand(fnDeleteSession), - Name: "delete-session", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Disconnect from Signal, clearing sessions but keeping other data. Reconnect with `login`", - }, -} - -func fnDeleteSession(ce *WrappedCommandEvent) { - if !ce.User.IsLoggedIn() { - ce.Reply("You're not logged in") - return - } - ce.User.Client.ClearKeysAndDisconnect(ce.Ctx) - ce.Reply("Disconnected from Signal") -} - -var cmdPing = &commands.FullHandler{ - Func: wrapCommand(fnPing), - Name: "ping", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Check your connection to Signal", - }, -} - -func fnPing(ce *WrappedCommandEvent) { - if ce.User.SignalID == uuid.Nil { - ce.Reply("You're not logged in") - } else if !ce.User.IsLoggedIn() { - ce.Reply("You were logged in at some point, but are not anymore") - } else if !ce.User.Client.IsConnected() { - ce.Reply("You're logged into Signal, but not connected to the server") - } else { - ce.Reply("You're logged into Signal and probably connected to the server") - } -} - -var cmdSetDeviceName = &commands.FullHandler{ - Func: wrapCommand(fnSetDeviceName), - Name: "set-device-name", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Set the name of this device in Signal", - Args: "", - }, - RequiresLogin: true, -} - -func fnSetDeviceName(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `set-device-name `") - return - } - - name := strings.Join(ce.Args, " ") - err := ce.User.Client.UpdateDeviceName(ce.Ctx, name) - if err != nil { - ce.Reply("Error setting device name: %v", err) - return - } - ce.Reply("Device name updated") -} - -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, -} - -var numberCleaner = strings.NewReplacer("-", "", " ", "", "(", "", ")", "", "+", "") - -func fnPM(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `pm `") - return - } - number, err := strconv.ParseUint(numberCleaner.Replace(strings.Join(ce.Args, "")), 10, 64) - if err != nil { - ce.Reply("Failed to parse number") - return - } - - user := ce.User - var aci, pni uuid.UUID - e164 := fmt.Sprintf("+%d", number) - var recipient *types.Recipient - - if recipient, err = user.Client.ContactByE164(ce.Ctx, e164); err != nil { - ce.Reply("Error looking up number in local contact list: %v", err) - return - } else if recipient != nil && (recipient.ACI != uuid.Nil || recipient.PNI != uuid.Nil) { - // TODO maybe lookup PNI if there's only ACI and E164 stored? - aci = recipient.ACI - pni = recipient.PNI - } else if resp, err := user.Client.LookupPhone(ce.Ctx, number); err != nil { - ce.ZLog.Err(err).Uint64("e164", number).Msg("Failed to lookup number on server") - ce.Reply("Error looking up number on server: %v", err) - return - } else { - aci = resp[number].ACI - pni = resp[number].PNI - if aci == uuid.Nil && pni == uuid.Nil { - ce.Reply("+%d doesn't seem to be on Signal", number) - return - } - recipient, err = user.Client.Store.RecipientStore.UpdateRecipientE164(ce.Ctx, aci, pni, e164) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save recipient entry after looking up phone") - } - aci, pni = recipient.ACI, recipient.PNI - } - ce.ZLog.Debug(). - Uint64("e164", number). - Stringer("aci", aci). - Stringer("pni", pni). - Msg("Found DM target user") - - var targetServiceID libsignalgo.ServiceID - if aci != uuid.Nil { - targetServiceID = libsignalgo.NewACIServiceID(aci) - } else { - targetServiceID = libsignalgo.NewPNIServiceID(pni) - } - portal := user.GetPortalByChatID(targetServiceID.String()) - if portal == nil { - ce.Reply("Couldn't get portal with %s/+%d", targetServiceID, number) - return - } else if portal.MXID != "" { - ok := portal.ensureUserInvited(ce.Ctx, ce.User) - if ok { - ce.Reply("You already have a portal with +%d at [%s](%s)", number, portal.MXID, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL()) - return - } - ce.ZLog.Warn().Stringer("existing_room_id", portal.MXID).Msg("Ensuring user is invited to existing room failed, creating new room") - portal.Cleanup(ce.Ctx, false) - portal.MXID = "" - } - - if err = portal.CreateMatrixRoom(ce.Ctx, user, 0); err != nil { - ce.ZLog.Err(err).Msg("Failed to create portal room") - ce.Reply("Error creating Matrix room for portal to +%d", number) - } else { - ce.Reply("Created portal room [%s](%s) with +%d and invited you to it.", portal.MXID, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL(), number) - } -} - -var cmdInvite = &commands.FullHandler{ - Func: wrapCommand(fnInvite), - Name: "invite", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Invite a user by phone number.", - Args: "<_international phone number_>", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnInvite(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `invite `") - return - } - number, err := strconv.ParseUint(numberCleaner.Replace(strings.Join(ce.Args, "")), 10, 64) - if err != nil { - ce.Reply("Failed to parse number") - return - } - - user := ce.User - var aci, pni uuid.UUID - e164 := fmt.Sprintf("+%d", number) - var recipient *types.Recipient - - if recipient, err = user.Client.ContactByE164(ce.Ctx, e164); err != nil { - ce.Reply("Error looking up number in local contact list: %v", err) - return - } else if recipient != nil && (recipient.ACI != uuid.Nil || recipient.PNI != uuid.Nil) { - // TODO maybe lookup PNI if there's only ACI and E164 stored? - aci = recipient.ACI - pni = recipient.PNI - } else if resp, err := user.Client.LookupPhone(ce.Ctx, number); err != nil { - ce.ZLog.Err(err).Uint64("e164", number).Msg("Failed to lookup number on server") - ce.Reply("Error looking up number on server: %v", err) - return - } else { - aci = resp[number].ACI - pni = resp[number].PNI - if aci == uuid.Nil && pni == uuid.Nil { - ce.Reply("+%d doesn't seem to be on Signal", number) - return - } - recipient, err = user.Client.Store.RecipientStore.UpdateRecipientE164(ce.Ctx, aci, pni, e164) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save recipient entry after looking up phone") - } - aci, pni = recipient.ACI, recipient.PNI - } - ce.ZLog.Debug(). - Uint64("e164", number). - Stringer("aci", aci). - Stringer("pni", pni). - Msg("Found Invite target user") - - var groupChange signalmeow.GroupChange - if aci != uuid.Nil { - groupChange.AddMembers = []*signalmeow.AddMember{ - { - GroupMember: signalmeow.GroupMember{ - ACI: aci, - Role: signalmeow.GroupMember_DEFAULT, - }, - }, - } - } else { - groupChange.AddPendingMembers = []*signalmeow.PendingMember{ - { - ServiceID: libsignalgo.NewPNIServiceID(pni), - AddedByUserID: ce.User.SignalID, - Role: signalmeow.GroupMember_DEFAULT, - }, - } - } - revision, err := ce.User.Client.UpdateGroup(ce.Ctx, &groupChange, ce.Portal.GroupID()) - if err != nil { - ce.Reply("Failed to update group: %w", err) - return - } - ce.Portal.Revision = revision - if aci != uuid.Nil { - group, err := ce.User.Client.RetrieveGroupByID(ce.Ctx, ce.Portal.GroupID(), revision) - if err != nil { - ce.Reply("Failed to fetch group after invite: %w", err) - } - ce.Portal.SyncParticipants(ce.Ctx, user, group) - ce.Portal.Update(ce.Ctx) - return - } - ce.Portal.Update(ce.Ctx) - ce.Reply("Invited " + e164) -} - -var cmdListInvited = &commands.FullHandler{ - Func: wrapCommand(fnListInvited), - Name: "list-invited", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "list pending invites", - Args: "<_international phone number_>", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnListInvited(ce *WrappedCommandEvent) { - group, err := ce.User.Client.RetrieveGroupByID(ce.Ctx, ce.Portal.GroupID(), ce.Portal.Revision) - if err != nil { - ce.Reply("Failed to fetch group info: %w", err) - return - } - var memberList []string - for _, pendingMember := range group.PendingMembers { - recipientString, err := pendingMemberToString(ce.Ctx, ce.User, pendingMember) - if err != nil { - ce.Reply("Failed to fetch recipient for %s: %w", pendingMember.ServiceID, err) - continue - } - memberList = append(memberList, recipientString) - } - if len(memberList) == 0 { - ce.Reply("No pending Invites") - } else { - ce.Reply(strings.Join(memberList, "\n")) - } -} - -func pendingMemberToString(ctx context.Context, user *User, pendingMember *signalmeow.PendingMember) (string, error) { - var pni, aci uuid.UUID - if pendingMember.ServiceID.Type == libsignalgo.ServiceIDTypeACI { - aci = pendingMember.ServiceID.UUID - } else { - pni = pendingMember.ServiceID.UUID - } - recipient, err := user.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil) - if err != nil { - return "", err - } - if recipient.E164 != "" { - return recipient.E164, nil - } else { - return "Unidentified User", nil - } -} - -var cmdRevokeInvite = &commands.FullHandler{ - Func: wrapCommand(fnRevokeInvite), - Name: "revoke-invite", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Revoke an invite by phone number.", - Args: "<_international phone number_>", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnRevokeInvite(ce *WrappedCommandEvent) { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `RevokeInvite `") - return - } - e164 := "+" + numberCleaner.Replace(strings.Join(ce.Args, "")) - - user := ce.User - var serviceID libsignalgo.ServiceID - group, err := ce.User.Client.RetrieveGroupByID(ce.Ctx, ce.Portal.GroupID(), ce.Portal.Revision) - if err != nil { - ce.Reply("Failed to fetch group info: %w", err) - return - } - var pni, aci uuid.UUID - for _, pendingMember := range group.PendingMembers { - if pendingMember.ServiceID.Type == libsignalgo.ServiceIDTypeACI { - aci = pendingMember.ServiceID.UUID - } else { - pni = pendingMember.ServiceID.UUID - } - recipient, err := user.Client.Store.RecipientStore.LoadAndUpdateRecipient(ce.Ctx, aci, pni, nil) - if err != nil { - continue - } - if recipient.E164 == e164 { - serviceID = pendingMember.ServiceID - break - } - } - if serviceID.UUID == uuid.Nil { - ce.Reply("User not in Group") - return - } - var groupChange signalmeow.GroupChange - groupChange.DeletePendingMembers = []*libsignalgo.ServiceID{&serviceID} - revision, err := ce.User.Client.UpdateGroup(ce.Ctx, &groupChange, ce.Portal.GroupID()) - if err != nil { - ce.Reply("Failed to update group: %w", err) - return - } - if aci != uuid.Nil { - target := ce.Bridge.GetPuppetBySignalID(aci) - if target != nil { - ce.Bot.SendCustomMembershipEvent(ce.Ctx, ce.Portal.MXID, target.IntentFor(ce.Portal).UserID, event.MembershipLeave, "removed by "+user.MXID.String()) - } - } - ce.Portal.Revision = revision - ce.Portal.Update(ce.Ctx) - ce.Reply("Revoked the invitation for " + e164) -} - -var cmdResolvePhone = &commands.FullHandler{ - Func: wrapCommand(fnResolvePhone), - Name: "resolve-phone", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Look up phone numbers on the Signal servers.", - Args: "", - }, - RequiresLogin: true, -} - -func fnResolvePhone(ce *WrappedCommandEvent) { - numbers := make([]uint64, len(ce.Args)) - for i, arg := range ce.Args { - var err error - numbers[i], err = strconv.ParseUint(numberCleaner.Replace(arg), 10, 64) - if err != nil { - ce.Reply("Failed to parse number %s: %v", arg, err) - return - } - } - resp, err := ce.User.Client.LookupPhone(ce.Ctx, numbers...) - if err != nil { - ce.Reply("Failed to look up: %v", err) - } else { - var out strings.Builder - for _, phone := range numbers { - result, found := resp[phone] - if found { - _, _ = fmt.Fprintf(&out, "+%d: %s / %s\n", phone, result.ACI, result.PNI) - } else { - _, _ = fmt.Fprintf(&out, "+%d: not found\n", phone) - } - } - ce.Reply(strings.TrimSpace(out.String())) - } -} - -var cmdSync = &commands.FullHandler{ - Func: wrapCommand(fnSync), - Name: "sync", - Help: commands.HelpMeta{ - Section: HelpSectionMiscellaneous, - Description: "Synchronize Signal bridge data", - Args: "", - }, - RequiresLogin: true, -} - -func fnSync(ce *WrappedCommandEvent) { - args := strings.ToLower(strings.Join(ce.Args, " ")) - space := strings.Contains(args, "space") - groups := strings.Contains(args, "groups") - if !space && !groups { - ce.Reply("**Usage:** `sync `") - return - } - if !ce.Bridge.Config.Bridge.PersonalFilteringSpaces && space { - ce.Reply("Personal filtering spaces are not enabled on this instance of the bridge") - return - } - ctx := ce.Ctx - dmKeys, err := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ctx, ce.User.SignalID) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to get private chat keys") - ce.Reply("Failed to get private chat IDs from database") - return - } - allPortals := ce.Bridge.GetAllPortalsWithMXID() - if space { - count := 0 - for _, portal := range allPortals { - if portal.IsPrivateChat() { - continue - } - if ce.Bridge.StateStore.IsInRoom(ctx, portal.MXID, ce.User.MXID) && portal.addToPersonalSpace(ctx, ce.User) { - count++ - } - } - for _, key := range dmKeys { - portal := ce.Bridge.GetPortalByChatID(key) - portal.addToPersonalSpace(ctx, ce.User) - count++ - } - plural := "s" - if count == 1 { - plural = "" - } - ce.Reply("Added %d room%s to space", count, plural) - } - if groups { - count := 0 - for _, portal := range allPortals { - if portal.IsPrivateChat() { - continue - } - if ce.Bridge.StateStore.IsInRoom(ctx, portal.MXID, ce.User.MXID) { - groupInfo := portal.UpdateGroupInfo(ce.Ctx, ce.User, nil, 0, true) - if groupInfo != nil { - members := portal.SyncParticipants(ctx, ce.User, groupInfo) - portal.updatePowerLevelsAndJoinRule(ctx, groupInfo, members) - count++ - } - } - } - plural := "s" - if count == 1 { - plural = "" - } - ce.Reply("Synced %d group%s", count, plural) - } -} - -var cmdLogin = &commands.FullHandler{ - Func: wrapCommand(fnLogin), - Name: "login", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Link the bridge to your Signal account as a web client.", - }, -} - -func fnLogin(ce *WrappedCommandEvent) { - if ce.User.IsLoggedIn() { - if ce.User.Client.IsConnected() { - ce.Reply("You're already logged in") - } else { - ce.Reply("You're already logged in, but not connected 🤔") - } - return - } - - var qrEventID, msgEventID id.EventID - var signalID uuid.UUID - var signalPhone string - - // First get the provisioning URL - provChan, err := ce.User.Login() - if err != nil { - ce.ZLog.Err(err).Msg("Failure logging in") - ce.Reply("Failure logging in: %v", err) - return - } - - resp := <-provChan - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - ce.Reply("Error getting provisioning URL: %v", resp.Err) - return - } - if resp.State == signalmeow.StateProvisioningURLReceived { - qrEventID, msgEventID = ce.User.sendQR(ce, resp.ProvisioningURL, qrEventID, msgEventID) - } else { - ce.Reply("Unexpected state: %v", resp.State) - return - } - - // Next, get the results of finishing registration - resp = <-provChan - _, _ = ce.Bot.RedactEvent(ce.Ctx, ce.RoomID, qrEventID) - _, _ = ce.Bot.RedactEvent(ce.Ctx, ce.RoomID, msgEventID) - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - if resp.Err != nil && strings.HasSuffix(resp.Err.Error(), " EOF") { - ce.Reply("Logging in timed out, please try again.") - } else { - ce.Reply("Error finishing registration: %v", resp.Err) - } - return - } - if resp.State == signalmeow.StateProvisioningDataReceived { - signalID = resp.ProvisioningData.ACI - signalPhone = resp.ProvisioningData.Number - } else { - ce.Reply("Unexpected state: %v", resp.State) - return - } - - // Finally, get the results of generating and registering prekeys - resp = <-provChan - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - ce.Reply("Error with prekeys: %v", resp.Err) - return - } else if resp.State != signalmeow.StateProvisioningPreKeysRegistered { - ce.Reply("Unexpected state: %v", resp.State) - return - } - - if signalID == uuid.Nil { - ce.Reply("Problem logging in - No SignalID received") - return - } - ce.User.saveSignalID(ce.Ctx, signalID, signalPhone) - - // Connect to Signal - ce.User.Connect() - ce.Reply("Successfully logged in as %s (UUID: %s)", ce.User.SignalUsername, ce.User.SignalID) -} - -func (user *User) sendQR(ce *WrappedCommandEvent, code string, prevQR, prevMsg id.EventID) (qr, msg id.EventID) { - content, ok := user.uploadQR(ce, code) - if !ok { - return prevQR, prevMsg - } - if len(prevQR) != 0 { - content.SetEdit(prevQR) - } - resp, err := ce.Bot.SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, &content) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to send QR code to user") - } else if len(prevQR) == 0 { - prevQR = resp.EventID - } - content = event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Raw linking URI: %s", code), - Format: event.FormatHTML, - FormattedBody: fmt.Sprintf("Raw linking URI: %s", code), - } - if len(prevMsg) != 0 { - content.SetEdit(prevMsg) - } - resp, err = ce.Bot.SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, &content) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to send raw code to user") - } else if len(prevMsg) == 0 { - prevMsg = resp.EventID - } - return prevQR, prevMsg -} - -func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (event.MessageEventContent, bool) { - const size = 512 - qrCode, err := qrcode.Encode(code, qrcode.Low, size) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to encode QR code") - ce.Reply("Failed to encode QR code: %v", err) - return event.MessageEventContent{}, 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 event.MessageEventContent{}, false - } - return event.MessageEventContent{ - MsgType: event.MsgImage, - Info: &event.FileInfo{ - MimeType: "image/png", - Width: size, - Height: size, - Size: len(qrCode), - }, - Body: "qr.png", - URL: resp.ContentURI.CUString(), - }, true -} - -func canDeletePortal(ctx context.Context, portal *Portal, userID id.UserID) bool { - if len(portal.MXID) == 0 { - return false - } - - members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) - if err != nil { - portal.log.Err(err). - Stringer("user_id", userID). - Msg("Failed to get joined members to check if user can delete portal") - return false - } - for otherUser := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(otherUser) - if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID { - continue - } - user := portal.bridge.GetUserByMXID(otherUser) - if user != nil && user.IsLoggedIn() { - 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.Ctx, ce.Portal, ce.User.MXID) { - ce.Reply("Only bridge admins can delete portals with other Matrix users") - return - } - - ce.Portal.log.Info().Stringer("user_id", ce.User.MXID).Msg("User requested deletion of portal") - ce.Portal.Delete() - 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.GetAllPortalsWithMXID() - var portalsToDelete []*Portal - - if ce.User.Admin { - portalsToDelete = portals - } else { - portalsToDelete = portals[:0] - for _, portal := range portals { - if canDeletePortal(ce.Ctx, portal, ce.User.MXID) { - 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() - leave(portal) - } - ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") - - backgroundCtx := context.TODO() - go func() { - for _, portal := range portalsToDelete { - portal.Cleanup(backgroundCtx, false) - } - ce.Reply("Finished background cleanup of deleted portal rooms.") - }() -} - -var cmdCleanupLostPortals = &commands.FullHandler{ - Func: wrapCommand(fnCleanupLostPortals), - Name: "cleanup-lost-portals", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Clean up portals that were discarded due to the receiver not being logged into the bridge", - }, - RequiresAdmin: true, -} - -func fnCleanupLostPortals(ce *WrappedCommandEvent) { - portals, err := ce.Bridge.DB.LostPortal.GetAll(ce.Ctx) - if err != nil { - ce.Reply("Failed to get portals: %v", err) - return - } else if len(portals) == 0 { - ce.Reply("No lost portals found") - return - } - - ce.Reply("Found %d lost portals, deleting...", len(portals)) - for _, portal := range portals { - dmUUID, err := uuid.Parse(portal.ChatID) - intent := ce.Bot - if err == nil { - intent = ce.Bridge.GetPuppetBySignalID(dmUUID).DefaultIntent() - } - ce.Bridge.CleanupRoom(ce.Ctx, ce.ZLog, intent, portal.MXID, false) - err = portal.Delete(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to delete lost portal from database after cleanup") - } - } - ce.Reply("Finished cleaning up portals") -} - -var cmdInviteLink = &commands.FullHandler{ - Func: wrapCommand(fnInviteLink), - Name: "invite-link", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Get the invite link for the corresponding Signal Group", - }, - RequiresLogin: true, -} - -func fnInviteLink(ce *WrappedCommandEvent) { - if ce.Portal == nil { - ce.Reply("This is not a portal room") - return - } - if ce.Portal.IsPrivateChat() { - ce.Reply("Invite Links are not available for private chats") - return - } - inviteLinkPassword, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User) - if err != nil { - ce.Reply("Error getting invite link %w", err) - return - } - ce.Reply(inviteLinkPassword) -} - -var cmdResetInviteLink = &commands.FullHandler{ - Func: wrapCommand(fnResetInviteLink), - Name: "reset-invite-link", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Generate a new invite link password", - }, - RequiresLogin: true, -} - -func fnResetInviteLink(ce *WrappedCommandEvent) { - if ce.Portal == nil { - ce.Reply("This is not a portal room") - return - } - if ce.Portal.IsPrivateChat() { - ce.Reply("Invite Links are not available for private chats") - return - } - err := ce.Portal.ResetInviteLink(ce.Ctx, ce.User) - if err != nil { - ce.Reply("Error setting new invite link %w", err) - } - inviteLink, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User) - if err != nil { - ce.Reply("Error getting new invite link %w", err) - return - } - ce.Reply(inviteLink) -} - -var cmdCreate = &commands.FullHandler{ - Func: wrapCommand(fnCreate), - Name: "create", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Create a Signal 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 - } - - roomState, err := ce.Bot.State(ce.Ctx, ce.RoomID) - if err != nil { - ce.Reply("Failed to get room state: %w", err) - return - } - members := roomState[event.StateMember] - powerLevelsRaw, ok := roomState[event.StatePowerLevels][""] - if !ok { - ce.Reply("Failed to get room power levels") - return - } - powerLevelsRaw.Content.ParseRaw(event.StatePowerLevels) - powerLevels := powerLevelsRaw.Content.AsPowerLevels() - joinRulesRaw, ok := roomState[event.StateJoinRules][""] - if !ok { - ce.Reply("Failed to get join rules") - return - } - joinRulesRaw.Content.ParseRaw(event.StateJoinRules) - joinRule := joinRulesRaw.Content.AsJoinRules().JoinRule - roomNameEventRaw, ok := roomState[event.StateRoomName][""] - if !ok { - ce.Reply("Failed to get room name") - return - } - roomNameEventRaw.Content.ParseRaw(event.StateRoomName) - roomName := roomNameEventRaw.Content.AsRoomName().Name - if len(roomName) == 0 { - ce.Reply("Please set a name for the room first") - return - } - roomTopic := "" - roomTopicEvent, ok := roomState[event.StateTopic][""] - if ok { - roomTopicEvent.Content.ParseRaw(event.StateTopic) - roomTopic = roomTopicEvent.Content.AsTopic().Topic - } - roomAvatarEvent, ok := roomState[event.StateRoomAvatar][""] - var avatarHash string - var avatarURL id.ContentURI - var avatarBytes []byte - avatarSet := false - if ok { - roomAvatarEvent.Content.ParseRaw(event.StateRoomAvatar) - avatarURL = roomAvatarEvent.Content.AsRoomAvatar().URL.ParseOrIgnore() - if !avatarURL.IsEmpty() { - avatarBytes, err = ce.Bot.DownloadBytes(ce.Ctx, avatarURL) - if err != nil { - ce.ZLog.Err(err).Stringer("Failed to download updated avatar %s", avatarURL) - return - } - hash := sha256.Sum256(avatarBytes) - avatarHash = hex.EncodeToString(hash[:]) - ce.ZLog.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{ce.User.MXID, avatarURL}) - avatarSet = true - } - } - var encryptionEvent *event.EncryptionEventContent - encryptionEventContent, ok := roomState[event.StateEncryption][""] - if ok { - encryptionEventContent.Content.ParseRaw(event.StateEncryption) - encryptionEvent = encryptionEventContent.Content.AsEncryption() - } - var participants []*signalmeow.GroupMember - var bannedMembers []*signalmeow.BannedMember - participantDedup := make(map[uuid.UUID]bool) - participantDedup[uuid.Nil] = true - for key, member := range members { - mxid := id.UserID(key) - member.Content.ParseRaw(event.StateMember) - content := member.Content.AsMember() - membership := content.Membership - var uuid uuid.UUID - puppet := ce.Bridge.GetPuppetByMXID(mxid) - if puppet != nil { - uuid = puppet.SignalID - } else { - user := ce.Bridge.GetUserByMXID(mxid) - if user != nil && user.IsLoggedIn() { - uuid = user.SignalID - } - } - role := signalmeow.GroupMember_DEFAULT - if powerLevels.GetUserLevel(mxid) >= 50 { - role = signalmeow.GroupMember_ADMINISTRATOR - } - if !participantDedup[uuid] { - participantDedup[uuid] = true - // invites should be added on signal and then auto-joined - // joined members that need to be pending-Members should have their signal invite auto-accepted - if membership == event.MembershipJoin || membership == event.MembershipInvite { - participants = append(participants, &signalmeow.GroupMember{ - ACI: uuid, - Role: role, - }) - } else if membership == event.MembershipBan { - bannedMembers = append(bannedMembers, &signalmeow.BannedMember{ - ServiceID: libsignalgo.NewACIServiceID(uuid), - }) - } - } - } - addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE - if joinRule == event.JoinRulePublic { - addFromInviteLinkAccess = signalmeow.AccessControl_ANY - } else if joinRule == event.JoinRuleKnock { - addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR - } - var inviteLinkPassword types.SerializedInviteLinkPassword - if addFromInviteLinkAccess != signalmeow.AccessControl_UNSATISFIABLE { - inviteLinkPassword = signalmeow.GenerateInviteLinkPassword() - } - membersAccess := signalmeow.AccessControl_MEMBER - if powerLevels.Invite() >= 50 { - membersAccess = signalmeow.AccessControl_ADMINISTRATOR - } - attributesAccess := signalmeow.AccessControl_MEMBER - if powerLevels.StateDefault() >= 50 { - attributesAccess = signalmeow.AccessControl_ADMINISTRATOR - } - announcementsOnly := false - if powerLevels.EventsDefault >= 50 { - announcementsOnly = true - } - ce.ZLog.Info(). - Str("room_name", roomName). - Any("participants", participants). - Msg("Creating Signal group for Matrix room") - group, err := ce.User.Client.CreateGroup(ce.Ctx, &signalmeow.Group{ - Title: roomName, - Description: roomTopic, - Members: participants, - AccessControl: &signalmeow.GroupAccessControl{ - Members: membersAccess, - Attributes: attributesAccess, - AddFromInviteLink: addFromInviteLinkAccess, - }, - InviteLinkPassword: &inviteLinkPassword, - BannedMembers: bannedMembers, - AnnouncementsOnly: announcementsOnly, - }, avatarBytes) - if err != nil { - ce.Reply("Failed to create group: %v", err) - return - } - gid := group.GroupIdentifier - ce.ZLog.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("group_id", gid) - }) - portal := ce.User.GetPortalByChatID(gid.String()) - 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.Name = roomName - portal.Encrypted = encryptionEvent != nil && 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 - } - portal.Revision = group.Revision - portal.AvatarHash = avatarHash - portal.AvatarURL = avatarURL - portal.AvatarPath = group.AvatarPath - portal.AvatarSet = avatarSet - 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.Reply("Successfully created Signal group %s", gid.String()) -} diff --git a/config/bridge.go b/config/bridge.go deleted file mode 100644 index e3e756c..0000000 --- a/config/bridge.go +++ /dev/null @@ -1,241 +0,0 @@ -// mautrix-signal - A Matrix-signal 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 ( - "errors" - "fmt" - "strings" - "text/template" - "time" - - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -type BridgeConfig struct { - UsernameTemplate string `yaml:"username_template"` - DisplaynameTemplate string `yaml:"displayname_template"` - PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` - UseContactAvatars bool `yaml:"use_contact_avatars"` - UseOutdatedProfiles bool `yaml:"use_outdated_profiles"` - NumberInTopic bool `yaml:"number_in_topic"` - - NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"` - - PortalMessageBuffer int `yaml:"portal_message_buffer"` - - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - BridgeNotices bool `yaml:"bridge_notices"` - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageStatusEvents bool `yaml:"message_status_events"` - MessageErrorNotices bool `yaml:"message_error_notices"` - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - PublicPortals bool `yaml:"public_portals"` - CaptionInMessage bool `yaml:"caption_in_message"` - LocationFormat string `yaml:"location_format"` - FederateRooms bool `yaml:"federate_rooms"` - BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` - - DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` - - MessageHandlingTimeout struct { - ErrorAfterStr string `yaml:"error_after"` - DeadlineStr string `yaml:"deadline"` - - ErrorAfter time.Duration `yaml:"-"` - Deadline time.Duration `yaml:"-"` - } `yaml:"message_handling_timeout"` - - 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"` - - usernameTemplate *template.Template `yaml:"-"` - displaynameTemplate *template.Template `yaml:"-"` -} - -func (bc *BridgeConfig) GetResendBridgeInfo() bool { - return bc.ResendBridgeInfo -} - -func (bc *BridgeConfig) EnableMessageStatusEvents() bool { - return bc.MessageStatusEvents -} - -func (bc *BridgeConfig) EnableMessageErrorNotices() bool { - return bc.MessageErrorNotices -} - -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.usernameTemplate, 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 - } - - return nil -} - -var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil) - -func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig { - return bc.DoublePuppetConfig -} - -func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { - return bc.Encryption -} - -func (bc BridgeConfig) GetCommandPrefix() string { - return bc.CommandPrefix -} - -func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { - return bc.ManagementRoomText -} - -func (bc BridgeConfig) FormatUsername(userID string) string { - var buffer strings.Builder - _ = bc.usernameTemplate.Execute(&buffer, userID) - return buffer.String() -} - -type DisplaynameParams struct { - ProfileName string - ContactName string - Username string - PhoneNumber string - UUID string - ACI string - PNI string - AboutEmoji string -} - -func (bc BridgeConfig) FormatDisplayname(contact *types.Recipient) string { - var buffer strings.Builder - _ = bc.displaynameTemplate.Execute(&buffer, DisplaynameParams{ - ProfileName: contact.Profile.Name, - ContactName: contact.ContactName, - //Username: contact.Username, - PhoneNumber: contact.E164, - UUID: contact.ACI.String(), - ACI: contact.ACI.String(), - PNI: contact.PNI.String(), - AboutEmoji: contact.Profile.AboutEmoji, - }) - return buffer.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 de62c82..0000000 --- a/config/config.go +++ /dev/null @@ -1,44 +0,0 @@ -// mautrix-signal - A Matrix-Signal 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"` - - Metrics struct { - Enabled bool `yaml:"enabled"` - Listen string `yaml:"listen"` - } `yaml:"metrics"` - - Signal struct { - DeviceName string `yaml:"device_name"` - } `yaml:"signal"` - - 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 219b7fc..0000000 --- a/config/upgrade.go +++ /dev/null @@ -1,167 +0,0 @@ -// mautrix-signal - A Matrix-Signal 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 ( - "net/url" - "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) - - legacyDB, ok := helper.Get(up.Str, "appservice", "database") - if ok { - if strings.HasPrefix(legacyDB, "postgres") { - parsedDB, err := url.Parse(legacyDB) - if err != nil { - panic(err) - } - q := parsedDB.Query() - if parsedDB.Host == "" && !q.Has("host") { - q.Set("host", "/var/run/postgresql") - } else if !q.Has("sslmode") { - q.Set("sslmode", "disable") - } - parsedDB.RawQuery = q.Encode() - helper.Set(up.Str, parsedDB.String(), "appservice", "database", "uri") - helper.Set(up.Str, "postgres", "appservice", "database", "type") - } else { - dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///") - helper.Set(up.Str, dbPath, "appservice", "database", "uri") - helper.Set(up.Str, "sqlite3-fk-wal", "appservice", "database", "type") - } - } - if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok { - helper.Set(up.Int, legacyDBMinSize, "appservice", "database", "max_idle_conns") - } - if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok { - helper.Set(up.Int, legacyDBMaxSize, "appservice", "database", "max_open_conns") - } - if legacyBotUsername, ok := helper.Get(up.Str, "appservice", "bot_username"); ok { - helper.Set(up.Str, legacyBotUsername, "appservice", "bot", "username") - } - if legacyBotDisplayname, ok := helper.Get(up.Str, "appservice", "bot_displayname"); ok { - helper.Set(up.Str, legacyBotDisplayname, "appservice", "bot", "displayname") - } - if legacyBotAvatar, ok := helper.Get(up.Str, "appservice", "bot_avatar"); ok { - helper.Set(up.Str, legacyBotAvatar, "appservice", "bot", "avatar") - } - - helper.Copy(up.Bool, "metrics", "enabled") - helper.Copy(up.Str, "metrics", "listen") - - helper.Copy(up.Str, "signal", "device_name") - - if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") { - helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "bridge", "username_template") - } else { - helper.Copy(up.Str, "bridge", "username_template") - } - if displaynameTemplate, ok := helper.Get(up.Str, "bridge", "displayname_template"); ok && strings.Contains(displaynameTemplate, "{displayname}") { - helper.Set(up.Str, strings.ReplaceAll(displaynameTemplate, "{displayname}", `{{or .ProfileName .PhoneNumber "Unknown user"}}`), "bridge", "displayname_template") - } else { - helper.Copy(up.Str, "bridge", "displayname_template") - } - helper.Copy(up.Str, "bridge", "private_chat_portal_meta") - helper.Copy(up.Bool, "bridge", "use_contact_avatars") - helper.Copy(up.Bool, "bridge", "use_outdated_profiles") - helper.Copy(up.Bool, "bridge", "number_in_topic") - helper.Copy(up.Str, "bridge", "note_to_self_avatar") - helper.Copy(up.Int, "bridge", "portal_message_buffer") - helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") - helper.Copy(up.Bool, "bridge", "bridge_notices") - 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.Bool, "bridge", "sync_direct_chat_list") - helper.Copy(up.Bool, "bridge", "resend_bridge_info") - helper.Copy(up.Bool, "bridge", "public_portals") - helper.Copy(up.Bool, "bridge", "caption_in_message") - helper.Copy(up.Str, "bridge", "location_format") - helper.Copy(up.Bool, "bridge", "federate_rooms") - helper.Copy(up.Map, "bridge", "double_puppet_server_map") - helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery") - helper.Copy(up.Map, "bridge", "login_shared_secret_map") - helper.Copy(up.Str, "bridge", "command_prefix") - 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", "allow_key_sharing") - 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") - 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") - helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") - - helper.Copy(up.Str, "bridge", "provisioning", "prefix") - 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.Bool, "bridge", "provisioning", "debug_endpoints") - - helper.Copy(up.Map, "bridge", "permissions") - helper.Copy(up.Bool, "bridge", "relay", "enabled") - helper.Copy(up.Bool, "bridge", "relay", "admin_only") - if textRelayFormat, ok := helper.Get(up.Str, "bridge", "relay", "message_formats", "m.text"); ok && strings.Contains(textRelayFormat, "$message") && !strings.Contains(textRelayFormat, ".Message") { - // don't copy legacy message formats - } else { - helper.Copy(up.Map, "bridge", "relay", "message_formats") - } -} - -var SpacedBlocks = [][]string{ - {"homeserver", "software"}, - {"appservice"}, - {"appservice", "hostname"}, - {"appservice", "database"}, - {"appservice", "id"}, - {"appservice", "as_token"}, - {"metrics"}, - {"signal"}, - {"bridge"}, - {"bridge", "personal_filtering_spaces"}, - {"bridge", "command_prefix"}, - {"bridge", "management_room_text"}, - {"bridge", "encryption"}, - {"bridge", "provisioning"}, - {"bridge", "permissions"}, - {"logging"}, -} diff --git a/custompuppet.go b/custompuppet.go deleted file mode 100644 index cde3059..0000000 --- a/custompuppet.go +++ /dev/null @@ -1,97 +0,0 @@ -// mautrix-signal - A Matrix-signal 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" - - "maunium.net/go/mautrix/id" -) - -func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error { - puppet.CustomMXID = mxid - puppet.AccessToken = accessToken - 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.log.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.log.Debug().Msg("Checking if double puppeting needs to be enabled") - puppet := user.bridge.GetPuppetBySignalID(user.SignalID) - if puppet.CustomMXID == user.MXID { - user.log.Debug().Msg("User already has double-puppeting enabled") - // Custom puppet already enabled - return - } - puppet.CustomMXID = user.MXID - err := puppet.StartCustomMXID(true) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to login with shared secret") - } else { - // TODO leave rooms with default puppet - user.log.Debug().Msg("Successfully automatically enabled custom puppet") - } -} diff --git a/database/database.go b/database/database.go deleted file mode 100644 index daa365f..0000000 --- a/database/database.go +++ /dev/null @@ -1,53 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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 ( - _ "embed" - - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" - "go.mau.fi/util/dbutil" - - "go.mau.fi/mautrix-signal/database/upgrades" -) - -type Database struct { - *dbutil.Database - - User *UserQuery - Portal *PortalQuery - LostPortal *LostPortalQuery - Puppet *PuppetQuery - Message *MessageQuery - Reaction *ReactionQuery - DisappearingMessage *DisappearingMessageQuery -} - -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)}, - LostPortal: &LostPortalQuery{dbutil.MakeQueryHelper(db, newLostPortal)}, - Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)}, - Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)}, - Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)}, - DisappearingMessage: &DisappearingMessageQuery{dbutil.MakeQueryHelper(db, newDisappearingMessage)}, - } -} diff --git a/database/disappearingmessage.go b/database/disappearingmessage.go deleted file mode 100644 index b32a8ee..0000000 --- a/database/disappearingmessage.go +++ /dev/null @@ -1,125 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getUnscheduledDisappearingMessagesForRoomQuery = ` - SELECT room_id, mxid, expiration_seconds, expiration_ts - FROM disappearing_message WHERE expiration_ts IS NULL AND room_id = $1 - ` - getExpiredDisappearingMessagesQuery = ` - SELECT room_id, mxid, expiration_seconds, expiration_ts - FROM disappearing_message WHERE expiration_ts IS NOT NULL AND expiration_ts <= $1 - ` - getNextDisappearingMessageQuery = ` - SELECT room_id, mxid, expiration_seconds, expiration_ts - FROM disappearing_message WHERE expiration_ts IS NOT NULL ORDER BY expiration_ts ASC LIMIT 1 - ` - insertDisappearingMessageQuery = ` - INSERT INTO disappearing_message (room_id, mxid, expiration_seconds, expiration_ts) VALUES ($1, $2, $3, $4) - ` - updateDisappearingMessageQuery = ` - UPDATE disappearing_message SET expiration_ts=$2 WHERE mxid=$1 - ` - deleteDisappearingMessageQuery = ` - DELETE FROM disappearing_message WHERE mxid=$1 - ` -) - -type DisappearingMessageQuery struct { - *dbutil.QueryHelper[*DisappearingMessage] -} - -type DisappearingMessage struct { - qh *dbutil.QueryHelper[*DisappearingMessage] - - RoomID id.RoomID - EventID id.EventID - ExpireIn time.Duration - ExpireAt time.Time -} - -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 { - return &DisappearingMessage{ - qh: dmq.QueryHelper, - RoomID: roomID, - EventID: eventID, - ExpireIn: expireIn, - ExpireAt: expireAt, - } -} - -func (dmq *DisappearingMessageQuery) GetUnscheduledForRoom(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, getUnscheduledDisappearingMessagesForRoomQuery, roomID) -} - -func (dmq *DisappearingMessageQuery) GetExpiredMessages(ctx context.Context) ([]*DisappearingMessage, error) { - return dmq.QueryMany(ctx, getExpiredDisappearingMessagesQuery, time.Now().Unix()+1) -} - -func (dmq *DisappearingMessageQuery) GetNextScheduledMessage(ctx context.Context) (*DisappearingMessage, error) { - return dmq.QueryOne(ctx, getNextDisappearingMessageQuery) -} - -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.Second - if expireAt.Valid { - msg.ExpireAt = time.Unix(expireAt.Int64, 0) - } - return msg, nil -} - -func (msg *DisappearingMessage) sqlVariables() []any { - var expireAt sql.NullInt64 - if !msg.ExpireAt.IsZero() { - expireAt.Valid = true - expireAt.Int64 = msg.ExpireAt.Unix() - } - return []any{msg.RoomID, msg.EventID, int64(msg.ExpireIn.Seconds()), expireAt} -} - -func (msg *DisappearingMessage) Insert(ctx context.Context) error { - return msg.qh.Exec(ctx, insertDisappearingMessageQuery, msg.sqlVariables()...) -} - -func (msg *DisappearingMessage) StartExpirationTimer(ctx context.Context) error { - msg.ExpireAt = time.Now().Add(msg.ExpireIn) - return msg.qh.Exec(ctx, updateDisappearingMessageQuery, msg.EventID, msg.ExpireAt.Unix()) -} - -func (msg *DisappearingMessage) Delete(ctx context.Context) error { - return msg.qh.Exec(ctx, deleteDisappearingMessageQuery, msg.EventID) -} diff --git a/database/lostportal.go b/database/lostportal.go deleted file mode 100644 index 29779b8..0000000 --- a/database/lostportal.go +++ /dev/null @@ -1,58 +0,0 @@ -// mautrix-signal - A Matrix-signal 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 database - -import ( - "context" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getLostPortalsQuery = `SELECT chat_id, receiver, mxid FROM lost_portals` - deleteLostPortalQuery = `DELETE FROM lost_portals WHERE mxid=$1` -) - -type LostPortalQuery struct { - *dbutil.QueryHelper[*LostPortal] -} - -func (lpq *LostPortalQuery) GetAll(ctx context.Context) ([]*LostPortal, error) { - return lpq.QueryMany(ctx, getLostPortalsQuery) -} - -type LostPortal struct { - qh *dbutil.QueryHelper[*LostPortal] - - ChatID string - Receiver string - MXID id.RoomID -} - -func newLostPortal(qh *dbutil.QueryHelper[*LostPortal]) *LostPortal { - return &LostPortal{qh: qh} -} - -func (l *LostPortal) Scan(row dbutil.Scannable) (*LostPortal, error) { - err := row.Scan(&l.ChatID, &l.Receiver, &l.MXID) - return l, err -} - -func (l *LostPortal) Delete(ctx context.Context) error { - return l.qh.Exec(ctx, deleteLostPortalQuery, l.MXID) -} diff --git a/database/message.go b/database/message.go deleted file mode 100644 index 5184bd8..0000000 --- a/database/message.go +++ /dev/null @@ -1,179 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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/google/uuid" - "github.com/lib/pq" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getMessageByMXIDQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE mxid=$1 - ` - getMessagePartBySignalIDQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4 - ` - getLastMessagePartBySignalIDQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3 - ORDER BY part_index DESC LIMIT 1 - ` - getAllMessagePartsBySignalIDQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3 - ` - getMessageLastPartBySignalIDWithUnknownReceiverQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=$1 AND timestamp=$2 AND (signal_receiver=$3 OR signal_receiver='00000000-0000-0000-0000-000000000000') - ORDER BY part_index DESC LIMIT 1 - ` - getManyMessagesBySignalIDQueryPostgres = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=$1 AND (signal_receiver=$2 OR signal_receiver=$3) AND timestamp=ANY($4) - ORDER BY timestamp DESC, part_index DESC - ` - getManyMessagesBySignalIDQuerySQLite = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE sender=?1 AND (signal_receiver=?2 OR signal_receiver=?3) AND timestamp IN (?4) - ORDER BY timestamp DESC, part_index DESC - ` - getFirstBeforeQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE mx_room=$1 AND timestamp <= $2 - ORDER BY timestamp DESC - LIMIT 1 - ` - getMessagesBetweenTimeQuery = ` - SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message - WHERE signal_chat_id=$1 AND signal_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND part_index=0 - ORDER BY timestamp ASC - ` - insertMessageQuery = ` - INSERT INTO message (sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ` - deleteMessageQuery = ` - DELETE FROM message - WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4 - ` - updateMessageTimestampQuery = ` - UPDATE message SET timestamp=$4 WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3 - ` -) - -type MessageQuery struct { - *dbutil.QueryHelper[*Message] -} - -type Message struct { - qh *dbutil.QueryHelper[*Message] - - Sender uuid.UUID - Timestamp uint64 - PartIndex int - - SignalChatID string - SignalReceiver uuid.UUID - - MXID id.EventID - RoomID id.RoomID -} - -func newMessage(qh *dbutil.QueryHelper[*Message]) *Message { - return &Message{qh: qh} -} - -func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) { - return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid) -} - -func (mq *MessageQuery) GetBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, partIndex int, receiver uuid.UUID) (*Message, error) { - return mq.QueryOne(ctx, getMessagePartBySignalIDQuery, sender, timestamp, partIndex, receiver) -} - -func (mq *MessageQuery) GetLastPartBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) { - return mq.QueryOne(ctx, getLastMessagePartBySignalIDQuery, sender, timestamp, receiver) -} - -func (mq *MessageQuery) GetAllPartsBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) ([]*Message, error) { - return mq.QueryMany(ctx, getAllMessagePartsBySignalIDQuery, sender, timestamp, receiver) -} - -func (mq *MessageQuery) GetAllBetweenTimestamps(ctx context.Context, key PortalKey, min, max uint64) ([]*Message, error) { - return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, key.ChatID, key.Receiver, int64(min), int64(max)) -} - -func (mq *MessageQuery) GetLastPartBySignalIDWithUnknownReceiver(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) { - return mq.QueryOne(ctx, getMessageLastPartBySignalIDWithUnknownReceiverQuery, sender, timestamp, receiver) -} - -func (mq *MessageQuery) GetManyBySignalID(ctx context.Context, sender uuid.UUID, timestamps []uint64, receiver uuid.UUID, strictReceiver bool) ([]*Message, error) { - receiver2 := uuid.Nil - if strictReceiver { - receiver2 = receiver - } - if mq.GetDB().Dialect == dbutil.Postgres { - int64Array := make([]int64, len(timestamps)) - for i, timestamp := range timestamps { - int64Array[i] = int64(timestamp) - } - return mq.QueryMany(ctx, getManyMessagesBySignalIDQueryPostgres, sender, receiver, receiver2, pq.Array(int64Array)) - } else { - const varargIndex = 3 - arguments := make([]any, len(timestamps)+varargIndex) - placeholders := make([]string, len(timestamps)) - arguments[0] = sender - arguments[1] = receiver - arguments[2] = receiver2 - for i, timestamp := range timestamps { - arguments[i+varargIndex] = timestamp - placeholders[i] = fmt.Sprintf("?%d", i+varargIndex+1) - } - return mq.QueryMany(ctx, strings.Replace(getManyMessagesBySignalIDQuerySQLite, fmt.Sprintf("?%d", varargIndex+1), strings.Join(placeholders, ", "), 1), arguments...) - } -} - -func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) { - return dbutil.ValueOrErr(msg, row.Scan( - &msg.Sender, &msg.Timestamp, &msg.PartIndex, &msg.SignalChatID, &msg.SignalReceiver, &msg.MXID, &msg.RoomID, - )) -} - -func (msg *Message) sqlVariables() []any { - return []any{msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalChatID, msg.SignalReceiver, msg.MXID, msg.RoomID} -} - -func (msg *Message) Insert(ctx context.Context) error { - return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...) -} - -func (msg *Message) Delete(ctx context.Context) error { - return msg.qh.Exec(ctx, deleteMessageQuery, msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalReceiver) -} - -func (msg *Message) SetTimestamp(ctx context.Context, editTime uint64) error { - return msg.qh.Exec(ctx, updateMessageTimestampQuery, msg.Sender, msg.Timestamp, msg.SignalReceiver, editTime) -} diff --git a/database/portal.go b/database/portal.go deleted file mode 100644 index 786f084..0000000 --- a/database/portal.go +++ /dev/null @@ -1,206 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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" - - "github.com/google/uuid" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -const ( - portalBaseSelect = ` - SELECT chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url, - name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time - FROM portal - ` - getPortalByMXIDQuery = portalBaseSelect + `WHERE mxid=$1` - getPortalByChatIDQuery = portalBaseSelect + `WHERE chat_id=$1 AND receiver=$2` - getPortalsByReceiver = portalBaseSelect + `WHERE receiver=$1` - getPortalsByUser = portalBaseSelect + `WHERE chat_id=$1` - getAllPortalsWithMXIDQuery = portalBaseSelect + `WHERE mxid IS NOT NULL` - getChatsNotInSpaceQuery = ` - SELECT chat_id FROM portal - LEFT JOIN user_portal ON portal.chat_id=user_portal.portal_chat_id 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 ( - chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url, - name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ` - updatePortalQuery = ` - UPDATE portal SET - mxid=$3, name=$4, topic=$5, avatar_path=$6, avatar_hash=$7, avatar_url=$8, - name_set=$9, avatar_set=$10, topic_set=$11, revision=$12, encrypted=$13, relay_user_id=$14, expiration_time=$15 - WHERE chat_id=$1 AND receiver=$2 - ` - deletePortalQuery = `DELETE FROM portal WHERE chat_id=$1 AND receiver=$2` - reIDPortalQuery = `UPDATE portal SET chat_id=$2 WHERE chat_id=$1 AND receiver=$3` -) - -type PortalQuery struct { - *dbutil.QueryHelper[*Portal] -} - -type PortalKey struct { - ChatID string - Receiver uuid.UUID -} - -func (pk *PortalKey) UserID() libsignalgo.ServiceID { - parsed, _ := libsignalgo.ServiceIDFromString(pk.ChatID) - return parsed -} - -func (pk *PortalKey) GroupID() types.GroupIdentifier { - if len(pk.ChatID) == 44 { - return types.GroupIdentifier(pk.ChatID) - } - return "" -} - -func NewPortalKey(chatID string, receiver uuid.UUID) PortalKey { - return PortalKey{ - ChatID: chatID, - Receiver: receiver, - } -} - -type Portal struct { - qh *dbutil.QueryHelper[*Portal] - - PortalKey - MXID id.RoomID - Name string - Topic string - AvatarPath string - AvatarHash string - AvatarURL id.ContentURI - NameSet bool - AvatarSet bool - TopicSet bool - Revision uint32 - Encrypted bool - RelayUserID id.UserID - ExpirationTime uint32 -} - -func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal { - return &Portal{qh: qh} -} - -func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid) -} - -func (pq *PortalQuery) GetByChatID(ctx context.Context, pk PortalKey) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByChatIDQuery, pk.ChatID, pk.Receiver) -} - -func (pq *PortalQuery) FindPrivateChatsWith(ctx context.Context, userID uuid.UUID) ([]*Portal, error) { - return pq.QueryMany(ctx, getPortalsByUser, userID.String()) -} - -func (pq *PortalQuery) FindPrivateChatsOf(ctx context.Context, receiver uuid.UUID) ([]*Portal, error) { - return pq.QueryMany(ctx, getPortalsByReceiver, receiver) -} - -func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery) -} - -func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver uuid.UUID) ([]PortalKey, error) { - rows, err := pq.GetDB().Query(ctx, getChatsNotInSpaceQuery, receiver) - if err != nil { - return nil, err - } - return dbutil.NewRowIter(rows, func(rows dbutil.Scannable) (key PortalKey, err error) { - err = rows.Scan(&key.ChatID) - key.Receiver = receiver - return - }).AsList() -} - -func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { - var mxid sql.NullString - err := row.Scan( - &p.ChatID, - &p.Receiver, - &mxid, - &p.Name, - &p.Topic, - &p.AvatarPath, - &p.AvatarHash, - &p.AvatarURL, - &p.NameSet, - &p.AvatarSet, - &p.TopicSet, - &p.Revision, - &p.Encrypted, - &p.RelayUserID, - &p.ExpirationTime, - ) - if err != nil { - return nil, err - } - p.MXID = id.RoomID(mxid.String) - return p, nil -} - -func (p *Portal) sqlVariables() []any { - return []any{ - p.ChatID, - p.Receiver, - dbutil.StrPtr(p.MXID), - p.Name, - p.Topic, - p.AvatarPath, - p.AvatarHash, - &p.AvatarURL, - p.NameSet, - p.AvatarSet, - p.TopicSet, - p.Revision, - p.Encrypted, - p.RelayUserID, - p.ExpirationTime, - } -} - -func (p *Portal) Insert(ctx context.Context) error { - return p.qh.Exec(ctx, insertPortalQuery, p.sqlVariables()...) -} - -func (p *Portal) Update(ctx context.Context) error { - return p.qh.Exec(ctx, updatePortalQuery, p.sqlVariables()...) -} - -func (p *Portal) Delete(ctx context.Context) error { - return p.qh.Exec(ctx, deletePortalQuery, p.ChatID, p.Receiver) -} - -func (p *Portal) ReID(ctx context.Context, newID string) error { - return p.qh.Exec(ctx, reIDPortalQuery, p.ChatID, newID, p.Receiver) -} diff --git a/database/puppet.go b/database/puppet.go deleted file mode 100644 index 99a65ff..0000000 --- a/database/puppet.go +++ /dev/null @@ -1,158 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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/google/uuid" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - puppetBaseSelect = ` - SELECT uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, name_set, avatar_set, - contact_info_set, is_registered, profile_fetched_at, custom_mxid, access_token - FROM puppet - ` - getPuppetBySignalIDQuery = puppetBaseSelect + `WHERE uuid=$1` - getPuppetByNumberQuery = puppetBaseSelect + `WHERE number=$1` - getPuppetByCustomMXIDQuery = puppetBaseSelect + `WHERE custom_mxid=$1` - getPuppetsWithCustomMXID = puppetBaseSelect + `WHERE custom_mxid<>''` - updatePuppetQuery = ` - UPDATE puppet SET - number=$2, name=$3, name_quality=$4, avatar_path=$5, avatar_hash=$6, avatar_url=$7, - name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11, profile_fetched_at=$12, - custom_mxid=$13, access_token=$14 - WHERE uuid=$1 - ` - insertPuppetQuery = ` - INSERT INTO puppet ( - uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, - name_set, avatar_set, contact_info_set, is_registered, profile_fetched_at, - custom_mxid, access_token - ) - VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 - ) - ` -) - -type PuppetQuery struct { - *dbutil.QueryHelper[*Puppet] -} - -type Puppet struct { - qh *dbutil.QueryHelper[*Puppet] - - SignalID uuid.UUID - Number string - Name string - NameQuality int - AvatarPath string - AvatarHash string - AvatarURL id.ContentURI - NameSet bool - AvatarSet bool - - IsRegistered bool - ContactInfoSet bool - ProfileFetchedAt time.Time - - CustomMXID id.UserID - AccessToken string -} - -func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet { - return &Puppet{qh: qh} -} - -func (pq *PuppetQuery) GetBySignalID(ctx context.Context, signalID uuid.UUID) (*Puppet, error) { - return pq.QueryOne(ctx, getPuppetBySignalIDQuery, signalID) -} - -func (pq *PuppetQuery) GetByNumber(ctx context.Context, number string) (*Puppet, error) { - return pq.QueryOne(ctx, getPuppetByNumberQuery, number) -} - -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, getPuppetsWithCustomMXID) -} - -func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { - var number, customMXID sql.NullString - var profileFetchedAt sql.NullInt64 - err := row.Scan( - &p.SignalID, - &number, - &p.Name, - &p.NameQuality, - &p.AvatarPath, - &p.AvatarHash, - &p.AvatarURL, - &p.NameSet, - &p.AvatarSet, - &p.ContactInfoSet, - &p.IsRegistered, - &profileFetchedAt, - &customMXID, - &p.AccessToken, - ) - if err != nil { - return nil, err - } - p.Number = number.String - p.CustomMXID = id.UserID(customMXID.String) - if profileFetchedAt.Valid { - p.ProfileFetchedAt = time.UnixMilli(profileFetchedAt.Int64) - } - return p, nil -} - -func (p *Puppet) sqlVariables() []any { - return []any{ - p.SignalID, - dbutil.StrPtr(p.Number), - p.Name, - p.NameQuality, - p.AvatarPath, - p.AvatarHash, - &p.AvatarURL, - p.NameSet, - p.AvatarSet, - p.ContactInfoSet, - p.IsRegistered, - dbutil.UnixMilliPtr(p.ProfileFetchedAt), - dbutil.StrPtr(p.CustomMXID), - p.AccessToken, - } -} - -func (p *Puppet) Insert(ctx context.Context) error { - return p.qh.Exec(ctx, insertPuppetQuery, p.sqlVariables()...) -} - -func (p *Puppet) Update(ctx context.Context) error { - return p.qh.Exec(ctx, updatePuppetQuery, p.sqlVariables()...) -} diff --git a/database/reaction.go b/database/reaction.go deleted file mode 100644 index fc5228e..0000000 --- a/database/reaction.go +++ /dev/null @@ -1,97 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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" - - "github.com/google/uuid" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getReactionByMXIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE mxid=$1` - getReactionBySignalIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4` - insertReactionQuery = ` - INSERT INTO reaction (msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ` - updateReactionQuery = ` - UPDATE reaction - SET mxid=$1, emoji=$2 - WHERE msg_author=$3 AND msg_timestamp=$4 AND author=$5 AND signal_receiver=$6 - ` - deleteReactionQuery = ` - DELETE FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4 - ` -) - -type ReactionQuery struct { - *dbutil.QueryHelper[*Reaction] -} - -func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction { - return &Reaction{qh: qh} -} - -type Reaction struct { - qh *dbutil.QueryHelper[*Reaction] - - MsgAuthor uuid.UUID - MsgTimestamp uint64 - Author uuid.UUID - Emoji string - - SignalChatID string - SignalReceiver uuid.UUID - - MXID id.EventID - RoomID id.RoomID -} - -func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid) -} - -func (rq *ReactionQuery) GetBySignalID(ctx context.Context, msgAuthor uuid.UUID, msgTimestamp uint64, author, signalReceiver uuid.UUID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionBySignalIDQuery, msgAuthor, msgTimestamp, author, signalReceiver) -} - -func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) { - return dbutil.ValueOrErr(r, row.Scan( - &r.MsgAuthor, &r.MsgTimestamp, &r.Author, &r.Emoji, &r.SignalChatID, &r.SignalReceiver, &r.MXID, &r.RoomID, - )) -} - -func (r *Reaction) sqlVariables() []any { - return []any{ - r.MsgAuthor, r.MsgTimestamp, r.Author, r.Emoji, r.SignalChatID, r.SignalReceiver, r.MXID, r.RoomID, - } -} - -func (r *Reaction) Insert(ctx context.Context) error { - return r.qh.Exec(ctx, insertReactionQuery, r.sqlVariables()...) -} - -func (r *Reaction) Update(ctx context.Context) error { - return r.qh.Exec(ctx, updateReactionQuery, r.MXID, r.Emoji, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver) -} - -func (r *Reaction) Delete(ctx context.Context) error { - return r.qh.Exec(ctx, deleteReactionQuery, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver) -} diff --git a/database/upgrades/00-latest.sql b/database/upgrades/00-latest.sql deleted file mode 100644 index 4dec3ff..0000000 --- a/database/upgrades/00-latest.sql +++ /dev/null @@ -1,116 +0,0 @@ --- v0 -> v20 (compatible with v17+): Latest revision - -CREATE TABLE portal ( - chat_id TEXT NOT NULL, - receiver uuid NOT NULL, - mxid TEXT, - name TEXT NOT NULL, - topic TEXT NOT NULL, - encrypted BOOLEAN NOT NULL DEFAULT false, - avatar_path TEXT NOT NULL DEFAULT '', - avatar_hash TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - topic_set BOOLEAN NOT NULL DEFAULT false, - revision INTEGER NOT NULL DEFAULT 0, - - expiration_time BIGINT NOT NULL, - relay_user_id TEXT NOT NULL, - - PRIMARY KEY (chat_id, receiver), - CONSTRAINT portal_mxid_unique UNIQUE(mxid) -); - -CREATE TABLE puppet ( - uuid uuid PRIMARY KEY, - number TEXT UNIQUE, - name TEXT NOT NULL, - name_quality INTEGER NOT NULL, - avatar_path TEXT NOT NULL, - avatar_hash TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - is_registered BOOLEAN NOT NULL DEFAULT false, - contact_info_set BOOLEAN NOT NULL DEFAULT false, - profile_fetched_at BIGINT, - - custom_mxid TEXT, - access_token TEXT NOT NULL, - - CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid) -); - -CREATE TABLE "user" ( - mxid TEXT PRIMARY KEY, - uuid uuid, - phone TEXT, - - management_room TEXT, - space_room TEXT, - - CONSTRAINT user_uuid_unique UNIQUE(uuid) -); - -CREATE TABLE user_portal ( - user_mxid TEXT, - portal_chat_id TEXT, - portal_receiver uuid, - last_read_ts BIGINT NOT NULL DEFAULT 0, - in_space BOOLEAN NOT NULL DEFAULT false, - - PRIMARY KEY (user_mxid, portal_chat_id, portal_receiver), - CONSTRAINT user_portal_user_fkey FOREIGN KEY (user_mxid) - REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT user_portal_portal_fkey FOREIGN KEY (portal_chat_id, portal_receiver) - REFERENCES portal(chat_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE -); - -CREATE TABLE message ( - sender uuid NOT NULL, - timestamp BIGINT NOT NULL, - part_index INTEGER NOT NULL, - - signal_chat_id TEXT NOT NULL, - signal_receiver uuid NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - PRIMARY KEY (sender, timestamp, part_index, signal_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (signal_chat_id, signal_receiver) - REFERENCES portal(chat_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE, - CONSTRAINT message_mxid_unique UNIQUE (mxid) -); - -CREATE TABLE reaction ( - msg_author uuid NOT NULL, - msg_timestamp BIGINT NOT NULL, - -- part_index is not used in reactions, but is required for the foreign key. - _part_index INTEGER NOT NULL DEFAULT 0, - - author uuid NOT NULL, - emoji TEXT NOT NULL, - - signal_chat_id TEXT NOT NULL, - signal_receiver uuid NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - PRIMARY KEY (msg_author, msg_timestamp, author, signal_receiver), - CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver) - REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE, - CONSTRAINT reaction_mxid_unique UNIQUE (mxid) -); - -CREATE TABLE disappearing_message ( - mxid TEXT NOT NULL PRIMARY KEY, - room_id TEXT NOT NULL, - expiration_seconds BIGINT NOT NULL, - expiration_ts BIGINT -); diff --git a/database/upgrades/13-upgrade-mx-state-store.sql b/database/upgrades/13-upgrade-mx-state-store.sql deleted file mode 100644 index ec1b6c2..0000000 --- a/database/upgrades/13-upgrade-mx-state-store.sql +++ /dev/null @@ -1,18 +0,0 @@ --- v13: Switch mx_room_state from Python to Go format -ALTER TABLE mx_room_state DROP COLUMN is_encrypted; -ALTER TABLE mx_room_state DROP COLUMN has_full_member_list; - --- only: postgres for next 2 lines -ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb; -ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb; - -ALTER TABLE "user" ADD COLUMN management_room TEXT; - -UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL; -UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL; - -CREATE TABLE mx_registrations ( - user_id TEXT PRIMARY KEY -); - -UPDATE mx_version SET version=5; diff --git a/database/upgrades/14-remove-notice-room.sql b/database/upgrades/14-remove-notice-room.sql deleted file mode 100644 index 9ad0692..0000000 --- a/database/upgrades/14-remove-notice-room.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v14: Remove redundant notice_room column from users -UPDATE "user" SET management_room = COALESCE(management_room, notice_room); -ALTER TABLE "user" DROP COLUMN notice_room; diff --git a/database/upgrades/15-remove-unused-puppet-columns.sql b/database/upgrades/15-remove-unused-puppet-columns.sql deleted file mode 100644 index 925c3a8..0000000 --- a/database/upgrades/15-remove-unused-puppet-columns.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v15: Remove unused columns in puppet table -ALTER TABLE puppet DROP COLUMN next_batch; -ALTER TABLE puppet DROP COLUMN base_url; diff --git a/database/upgrades/16-refactor-postgres.sql b/database/upgrades/16-refactor-postgres.sql deleted file mode 100644 index 9ab94e8..0000000 --- a/database/upgrades/16-refactor-postgres.sql +++ /dev/null @@ -1,123 +0,0 @@ --- v16: Refactor types (Postgres) --- only: postgres - -DROP TABLE IF EXISTS user_portal; - --- Drop constraints so we can fix timestamps. -ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey; -ALTER TABLE message DROP CONSTRAINT message_pkey; - --- Add part index to message and fix the hacky timestamps -ALTER TABLE message ADD COLUMN part_index INTEGER; -UPDATE message - SET timestamp=CASE WHEN timestamp > 1500000000000000 THEN timestamp / 1000 ELSE timestamp END, - part_index=CASE WHEN timestamp > 1500000000000000 THEN timestamp % 1000 ELSE 0 END; --- If the bridge users have reacted to message parts, forget about those, not worth trying to deal with potential conflicts. -DELETE FROM reaction WHERE msg_timestamp > 1500000000000000; -ALTER TABLE message ALTER COLUMN part_index SET NOT NULL; -ALTER TABLE reaction ADD COLUMN _part_index INTEGER NOT NULL DEFAULT 0; - --- Re-add the dropped constraints (but with part index and no chat) -DELETE FROM message - WHERE (sender, timestamp, part_index, signal_receiver) - IN (SELECT DISTINCT sender, timestamp, part_index, signal_receiver FROM message GROUP BY (sender, timestamp, part_index, signal_receiver) HAVING COUNT(*)>1); -ALTER TABLE message ADD PRIMARY KEY (sender, timestamp, part_index, signal_receiver); -ALTER TABLE message DROP CONSTRAINT IF EXISTS message_signal_chat_id_signal_receiver_fkey; -ALTER TABLE message DROP CONSTRAINT IF EXISTS message_signal_chat_id_fkey; --- Also update the reaction primary key -ALTER TABLE reaction DROP CONSTRAINT reaction_pkey; -ALTER TABLE reaction ADD PRIMARY KEY (author, msg_author, msg_timestamp, signal_receiver); - --- Change unique constraint from (mxid, mx_room) to just mxid. -ALTER TABLE message DROP CONSTRAINT message_mxid_mx_room_key; -ALTER TABLE message ADD CONSTRAINT message_mxid_unique UNIQUE (mxid); -ALTER TABLE reaction DROP CONSTRAINT reaction_mxid_mx_room_key; -ALTER TABLE reaction ADD CONSTRAINT reaction_mxid_unique UNIQUE (mxid); - -CREATE TABLE lost_portals ( - mxid TEXT PRIMARY KEY, - chat_id TEXT, - receiver TEXT -); -INSERT INTO lost_portals SELECT mxid, chat_id, receiver FROM portal WHERE mxid<>''; - --- Make mxid column unique (requires using nulls for missing values) -UPDATE portal SET mxid=NULL WHERE mxid=''; -ALTER TABLE portal ADD CONSTRAINT portal_mxid_unique UNIQUE(mxid); --- Delete any portals that aren't associated with logged-in users. -DELETE FROM portal WHERE receiver<>'' AND receiver NOT IN (SELECT username FROM "user" WHERE username IS NOT NULL AND uuid IS NOT NULL); --- CASCADE manually -DELETE FROM message - WHERE (signal_chat_id, signal_receiver) - NOT IN (SELECT DISTINCT signal_chat_id, signal_receiver FROM message WHERE (signal_chat_id, signal_receiver) IN (SELECT DISTINCT chat_id, receiver FROM portal)); -DELETE FROM reaction - WHERE (author, msg_author, msg_timestamp, signal_receiver) - NOT IN (SELECT DISTINCT author, msg_author, msg_timestamp, signal_receiver FROM reaction WHERE (msg_author, msg_timestamp, _part_index, signal_receiver) IN (SELECT DISTINCT sender, timestamp, part_index, signal_receiver FROM message)); --- Change receiver to uuid instead of phone number, also add nil uuid for groups. -UPDATE portal SET receiver=(SELECT uuid FROM "user" WHERE username=receiver AND uuid IS NOT NULL LIMIT 1) WHERE receiver<>''; -UPDATE portal SET receiver='00000000-0000-0000-0000-000000000000' WHERE receiver=''; --- CASCADE manually -UPDATE message SET signal_receiver=(SELECT uuid FROM "user" WHERE username=signal_receiver AND uuid IS NOT NULL LIMIT 1) WHERE signal_receiver<>''; -UPDATE message SET signal_receiver='00000000-0000-0000-0000-000000000000' WHERE signal_receiver=''; -UPDATE reaction SET signal_receiver=(SELECT uuid FROM "user" WHERE username=signal_receiver AND uuid IS NOT NULL LIMIT 1) WHERE signal_receiver<>''; -UPDATE reaction SET signal_receiver='00000000-0000-0000-0000-000000000000' WHERE signal_receiver=''; --- Change column types -ALTER TABLE portal ALTER COLUMN receiver TYPE uuid USING receiver::uuid; -ALTER TABLE message ALTER COLUMN signal_receiver TYPE uuid USING signal_receiver::uuid; -ALTER TABLE reaction ALTER COLUMN signal_receiver TYPE uuid USING signal_receiver::uuid; --- Re-add the dropped constraints again -ALTER TABLE message ADD CONSTRAINT message_portal_fkey - FOREIGN KEY (signal_chat_id, signal_receiver) - REFERENCES portal (chat_id, receiver) - ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver) - REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE; --- Delete group v1 portal entries -DELETE FROM portal WHERE chat_id NOT LIKE '________-____-____-____-____________' AND LENGTH(chat_id) <> 44; -DELETE FROM lost_portals WHERE mxid IN (SELECT mxid FROM portal WHERE mxid<>''); - --- Remove unnecessary nullables in portal -UPDATE portal SET name='' WHERE name IS NULL; -UPDATE portal SET topic='' WHERE topic IS NULL; -UPDATE portal SET avatar_hash='' WHERE avatar_hash IS NULL; -UPDATE portal SET avatar_url='' WHERE avatar_url IS NULL; -UPDATE portal SET expiration_time=0 WHERE expiration_time IS NULL; -UPDATE portal SET relay_user_id='' WHERE relay_user_id IS NULL; -ALTER TABLE portal ALTER COLUMN name SET NOT NULL; -ALTER TABLE portal ALTER COLUMN topic SET NOT NULL; -ALTER TABLE portal ALTER COLUMN avatar_hash SET NOT NULL; -ALTER TABLE portal ALTER COLUMN avatar_url SET NOT NULL; -ALTER TABLE portal ALTER COLUMN expiration_time SET NOT NULL; -ALTER TABLE portal ALTER COLUMN relay_user_id SET NOT NULL; - --- Add unique constraint to custom_mxid -UPDATE puppet - SET custom_mxid=NULL, access_token='' - WHERE custom_mxid<>'' - AND uuid<>COALESCE((SELECT uuid FROM "user" WHERE mxid=custom_mxid), '00000000-0000-0000-0000-000000000000'); -UPDATE puppet SET custom_mxid=NULL WHERE custom_mxid=''; -ALTER TABLE puppet ADD CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid); --- Remove unnecessary nullables in puppet -UPDATE puppet SET name='' WHERE name IS NULL; -UPDATE puppet SET avatar_hash='' WHERE avatar_hash IS NULL; -UPDATE puppet SET avatar_url='' WHERE avatar_url IS NULL; -UPDATE puppet SET access_token='' WHERE access_token IS NULL; -ALTER TABLE puppet ALTER COLUMN name SET NOT NULL; -ALTER TABLE puppet ALTER COLUMN avatar_hash SET NOT NULL; -ALTER TABLE puppet ALTER COLUMN avatar_url SET NOT NULL; -ALTER TABLE puppet ALTER COLUMN access_token SET NOT NULL; -ALTER TABLE puppet ALTER COLUMN name_quality DROP DEFAULT; - -UPDATE "user" - SET uuid=NULL - WHERE uuid IN (SELECT DISTINCT uuid FROM "user" WHERE uuid IS NOT NULL GROUP BY uuid HAVING COUNT(*)>1); -ALTER TABLE "user" ADD CONSTRAINT user_uuid_unique UNIQUE(uuid); -ALTER TABLE "user" RENAME COLUMN username TO phone; - --- Drop room_id from disappearing message primary key -ALTER TABLE disappearing_message DROP CONSTRAINT disappearing_message_pkey; -ALTER TABLE disappearing_message ADD PRIMARY KEY (mxid); --- Remove unnecessary nullables in disappearing_message -ALTER TABLE disappearing_message ALTER COLUMN room_id SET NOT NULL; -UPDATE disappearing_message SET expiration_seconds=0 WHERE expiration_seconds IS NULL; -ALTER TABLE disappearing_message ALTER COLUMN expiration_seconds SET NOT NULL; diff --git a/database/upgrades/17-refactor-sqlite.sql b/database/upgrades/17-refactor-sqlite.sql deleted file mode 100644 index f55dc0b..0000000 --- a/database/upgrades/17-refactor-sqlite.sql +++ /dev/null @@ -1,198 +0,0 @@ --- v17: Refactor types (SQLite) --- transaction: off --- only: sqlite - --- This is separate from v16 so that postgres can run with transaction: on --- (split upgrades by dialect don't currently allow disabling transaction in only one dialect) - -DROP TABLE IF EXISTS user_portal; - -PRAGMA foreign_keys = OFF; -BEGIN; - -CREATE TABLE message_new ( - sender uuid NOT NULL, - timestamp BIGINT NOT NULL, - part_index INTEGER NOT NULL, - - signal_chat_id TEXT NOT NULL, - signal_receiver TEXT NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - PRIMARY KEY (sender, timestamp, part_index, signal_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (signal_chat_id, signal_receiver) REFERENCES portal(chat_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE, - CONSTRAINT message_mxid_unique UNIQUE (mxid) -); - -CREATE TABLE reaction_new ( - msg_author uuid NOT NULL, - msg_timestamp BIGINT NOT NULL, - -- part_index is not used in reactions, but is required for the foreign key. - _part_index INTEGER NOT NULL DEFAULT 0, - - author uuid NOT NULL, - emoji TEXT NOT NULL, - - signal_chat_id TEXT NOT NULL, - signal_receiver TEXT NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - PRIMARY KEY (msg_author, msg_timestamp, author, signal_receiver), - CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver) - REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE, - CONSTRAINT reaction_mxid_unique UNIQUE (mxid) -); - - -DELETE FROM message - WHERE (sender, timestamp, signal_receiver) - IN (SELECT sender, timestamp, signal_receiver FROM message GROUP BY sender, timestamp, signal_receiver HAVING COUNT(*)>1); - -INSERT INTO message_new -SELECT sender, - CASE WHEN timestamp > 1500000000000000 THEN timestamp / 1000 ELSE timestamp END, - CASE WHEN timestamp > 1500000000000000 THEN timestamp % 1000 ELSE 0 END, - COALESCE(signal_chat_id, ''), - COALESCE(signal_receiver, ''), - mxid, - mx_room -FROM message; - -INSERT INTO reaction_new -SELECT msg_author, - msg_timestamp, - 0, -- _part_index - author, - emoji, - COALESCE(signal_chat_id, ''), - COALESCE(signal_receiver, ''), - mxid, - mx_room -FROM reaction -WHERE msg_timestamp<1500000000000000; - -DROP TABLE message; -DROP TABLE reaction; -ALTER TABLE message_new RENAME TO message; -ALTER TABLE reaction_new RENAME TO reaction; - -PRAGMA foreign_key_check; -COMMIT; - -PRAGMA foreign_keys = ON; - -BEGIN; -CREATE TABLE lost_portals ( - mxid TEXT PRIMARY KEY, - chat_id TEXT, - receiver TEXT -); -INSERT INTO lost_portals SELECT mxid, chat_id, receiver FROM portal WHERE mxid<>''; -DELETE FROM portal WHERE receiver<>'' AND receiver NOT IN (SELECT username FROM "user" WHERE username IS NOT NULL AND uuid<>''); -UPDATE portal SET receiver=(SELECT uuid FROM "user" WHERE username=receiver AND uuid<>'' LIMIT 1) WHERE receiver<>''; -UPDATE portal SET receiver='00000000-0000-0000-0000-000000000000' WHERE receiver=''; -DELETE FROM portal WHERE chat_id NOT LIKE '________-____-____-____-____________' AND LENGTH(chat_id) <> 44; -DELETE FROM lost_portals WHERE mxid IN (SELECT mxid FROM portal WHERE mxid<>''); -COMMIT; - -PRAGMA foreign_keys = OFF; - -BEGIN; - -CREATE TABLE portal_new ( - chat_id TEXT NOT NULL, - receiver uuid NOT NULL, - mxid TEXT, - name TEXT NOT NULL, - topic TEXT NOT NULL, - encrypted BOOLEAN NOT NULL DEFAULT false, - avatar_hash TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - revision INTEGER NOT NULL DEFAULT 0, - - expiration_time BIGINT NOT NULL, - relay_user_id TEXT NOT NULL, - - PRIMARY KEY (chat_id, receiver), - CONSTRAINT portal_mxid_unique UNIQUE(mxid) -); - -INSERT INTO portal_new - SELECT chat_id, receiver, CASE WHEN mxid='' THEN NULL ELSE mxid END, - COALESCE(name, ''), COALESCE(topic, ''), encrypted, COALESCE(avatar_hash, ''), COALESCE(avatar_url, ''), - name_set, avatar_set, revision, COALESCE(expiration_time, 0), COALESCE(relay_user_id, '') - FROM portal; -DROP TABLE portal; -ALTER TABLE portal_new RENAME TO portal; - -CREATE TABLE puppet_new ( - uuid uuid PRIMARY KEY, - number TEXT UNIQUE, - name TEXT NOT NULL, - name_quality INTEGER NOT NULL, - avatar_hash TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - is_registered BOOLEAN NOT NULL DEFAULT false, - contact_info_set BOOLEAN NOT NULL DEFAULT false, - - custom_mxid TEXT, - access_token TEXT NOT NULL, - - CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid) -); - -UPDATE puppet - SET custom_mxid=NULL, access_token='' - WHERE custom_mxid<>'' - AND uuid<>COALESCE((SELECT uuid FROM "user" WHERE mxid=custom_mxid), '00000000-0000-0000-0000-000000000000'); -INSERT INTO puppet_new - SELECT uuid, number, COALESCE(name, ''), COALESCE(name_quality, 0), COALESCE(avatar_hash, ''), - COALESCE(avatar_url, ''), name_set, avatar_set, is_registered, contact_info_set, - CASE WHEN custom_mxid='' THEN NULL ELSE custom_mxid END, COALESCE(access_token, '') - FROM puppet; -DROP TABLE puppet; -ALTER TABLE puppet_new RENAME TO puppet; - -CREATE TABLE user_new ( - mxid TEXT PRIMARY KEY, - uuid uuid, - phone TEXT, - - management_room TEXT, - - CONSTRAINT user_uuid_unique UNIQUE(uuid) -); - -INSERT INTO user_new - SELECT mxid, uuid, username, management_room - FROM user; -DROP TABLE user; -ALTER TABLE user_new RENAME TO user; - -CREATE TABLE disappearing_message_new ( - mxid TEXT NOT NULL PRIMARY KEY, - room_id TEXT NOT NULL, - expiration_seconds BIGINT NOT NULL, - expiration_ts BIGINT -); - -INSERT INTO disappearing_message_new - SELECT mxid, room_id, COALESCE(expiration_seconds, 0), expiration_ts - FROM disappearing_message; -DROP TABLE disappearing_message; -ALTER TABLE disappearing_message_new RENAME TO disappearing_message; - -PRAGMA foreign_key_check; -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/database/upgrades/18-spaces.sql b/database/upgrades/18-spaces.sql deleted file mode 100644 index 24248ba..0000000 --- a/database/upgrades/18-spaces.sql +++ /dev/null @@ -1,17 +0,0 @@ --- v18 (compatible with v17+): Add columns for personal filtering space info -ALTER TABLE "user" ADD COLUMN space_room TEXT; - -DROP TABLE IF EXISTS user_portal; -CREATE TABLE user_portal ( - user_mxid TEXT, - portal_chat_id TEXT, - portal_receiver uuid, - last_read_ts BIGINT NOT NULL DEFAULT 0, - in_space BOOLEAN NOT NULL DEFAULT false, - - PRIMARY KEY (user_mxid, portal_chat_id, portal_receiver), - CONSTRAINT user_portal_user_fkey FOREIGN KEY (user_mxid) - REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT user_portal_portal_fkey FOREIGN KEY (portal_chat_id, portal_receiver) - REFERENCES portal(chat_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE -); diff --git a/database/upgrades/19-more-portal-metadata.sql b/database/upgrades/19-more-portal-metadata.sql deleted file mode 100644 index 6230b21..0000000 --- a/database/upgrades/19-more-portal-metadata.sql +++ /dev/null @@ -1,5 +0,0 @@ --- v19 (compatible with v17+): Add more metadata for portals -ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false; -UPDATE portal SET topic_set=true WHERE topic<>''; -ALTER TABLE portal ADD COLUMN avatar_path TEXT NOT NULL DEFAULT ''; -ALTER TABLE puppet ADD COLUMN avatar_path TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/20-puppet-profile-fetch-ts.sql b/database/upgrades/20-puppet-profile-fetch-ts.sql deleted file mode 100644 index b398b2f..0000000 --- a/database/upgrades/20-puppet-profile-fetch-ts.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v20 (compatible with v17+): Add profile fetch timestamp for puppets -ALTER TABLE puppet ADD profile_fetched_at BIGINT; diff --git a/database/user.go b/database/user.go deleted file mode 100644 index aa90686..0000000 --- a/database/user.go +++ /dev/null @@ -1,115 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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" - - "github.com/google/uuid" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getUserByMXIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE mxid=$1` - getUserByPhoneQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone=$1` - getUserByUUIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE uuid=$1` - getAllLoggedInUsersQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone IS NOT NULL` - insertUserQuery = `INSERT INTO "user" (mxid, phone, uuid, management_room, space_room) VALUES ($1, $2, $3, $4, $5)` - updateUserQuery = `UPDATE "user" SET phone=$2, uuid=$3, management_room=$4, space_room=$5 WHERE mxid=$1` -) - -type UserQuery struct { - *dbutil.QueryHelper[*User] -} - -type User struct { - qh *dbutil.QueryHelper[*User] - - MXID id.UserID - SignalUsername string - SignalID uuid.UUID - ManagementRoom id.RoomID - SpaceRoom id.RoomID - - lastReadCache map[PortalKey]uint64 - lastReadCacheLock sync.Mutex - inSpaceCache map[PortalKey]bool - inSpaceCacheLock sync.Mutex -} - -func newUser(qh *dbutil.QueryHelper[*User]) *User { - return &User{ - qh: qh, - - lastReadCache: make(map[PortalKey]uint64), - inSpaceCache: make(map[PortalKey]bool), - } -} - -func (uq *UserQuery) GetByMXID(ctx context.Context, mxid id.UserID) (*User, error) { - return uq.QueryOne(ctx, getUserByMXIDQuery, mxid) -} - -func (uq *UserQuery) GetByPhone(ctx context.Context, phone string) (*User, error) { - return uq.QueryOne(ctx, getUserByPhoneQuery, phone) -} - -func (uq *UserQuery) GetBySignalID(ctx context.Context, uuid uuid.UUID) (*User, error) { - return uq.QueryOne(ctx, getUserByUUIDQuery, uuid) -} - -func (uq *UserQuery) GetAllLoggedIn(ctx context.Context) ([]*User, error) { - return uq.QueryMany(ctx, getAllLoggedInUsersQuery) -} - -func (u *User) sqlVariables() []any { - var nu uuid.NullUUID - nu.UUID = u.SignalID - nu.Valid = u.SignalID != uuid.Nil - return []any{u.MXID, dbutil.StrPtr(u.SignalUsername), nu, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.SpaceRoom)} -} - -func (u *User) Insert(ctx context.Context) error { - return u.qh.Exec(ctx, insertUserQuery, u.sqlVariables()...) -} - -func (u *User) Update(ctx context.Context) error { - return u.qh.Exec(ctx, updateUserQuery, u.sqlVariables()...) -} - -func (u *User) Scan(row dbutil.Scannable) (*User, error) { - var phone, managementRoom, spaceRoom sql.NullString - var signalID uuid.NullUUID - err := row.Scan( - &u.MXID, - &phone, - &signalID, - &managementRoom, - &spaceRoom, - ) - if err != nil { - return nil, err - } - u.SignalUsername = phone.String - u.SignalID = signalID.UUID - u.ManagementRoom = id.RoomID(managementRoom.String) - u.SpaceRoom = id.RoomID(spaceRoom.String) - return u, nil -} diff --git a/database/userportal.go b/database/userportal.go deleted file mode 100644 index 6081ee8..0000000 --- a/database/userportal.go +++ /dev/null @@ -1,116 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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" - - "github.com/rs/zerolog" -) - -const ( - getLastReadTSQuery = `SELECT last_read_ts FROM user_portal WHERE user_mxid=$1 AND portal_chat_id=$2 AND portal_receiver=$3` - setLastReadTSQuery = ` - INSERT INTO user_portal (user_mxid, portal_chat_id, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4) - ON CONFLICT (user_mxid, portal_chat_id, 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" - - "go.mau.fi/mautrix-signal/database" -) - -type DisappearingMessagesManager struct { - DB *database.Database - Log zerolog.Logger - Bridge *SignalBridge - checkMessagesChan chan struct{} -} - -func (dmm *DisappearingMessagesManager) ScheduleDisappearingForRoom(ctx context.Context, roomID id.RoomID) { - log := dmm.Log.With().Stringer("room_id", roomID).Logger() - disappearingMessages, err := dmm.DB.DisappearingMessage.GetUnscheduledForRoom(ctx, roomID) - if err != nil { - log.Err(err).Msg("Failed to get unscheduled disappearing messages") - return - } - for _, disappearingMessage := range disappearingMessages { - err = disappearingMessage.StartExpirationTimer(ctx) - if err != nil { - log.Err(err).Msg("Failed to schedule disappearing message") - } else { - log.Debug(). - Stringer("event_id", disappearingMessage.EventID). - Time("expire_at", disappearingMessage.ExpireAt). - Msg("Scheduling disappearing message") - } - } - - // Tell the disappearing messages loop to check again - dmm.checkMessagesChan <- struct{}{} -} - -func (dmm *DisappearingMessagesManager) StartDisappearingLoop(ctx context.Context) { - dmm.checkMessagesChan = make(chan struct{}, 1) - go func() { - log := dmm.Log.With().Str("action", "loop").Logger() - ctx = log.WithContext(ctx) - for { - dmm.redactExpiredMessages(ctx) - - duration := 10 * time.Minute // Check again in 10 minutes just in case - nextMsg, err := dmm.DB.DisappearingMessage.GetNextScheduledMessage(ctx) - if err != nil { - if ctx.Err() != nil { - return - } - log.Err(err).Msg("Failed to get next disappearing message") - continue - } else if nextMsg != nil { - duration = time.Until(nextMsg.ExpireAt) - } - - select { - case <-time.After(duration): - case <-dmm.checkMessagesChan: - case <-ctx.Done(): - return - } - } - }() -} - -func (dmm *DisappearingMessagesManager) redactExpiredMessages(ctx context.Context) { - log := zerolog.Ctx(ctx) - expiredMessages, err := dmm.DB.DisappearingMessage.GetExpiredMessages(ctx) - if err != nil { - log.Err(err).Msg("Failed to get expired disappearing messages") - return - } - - for _, msg := range expiredMessages { - portal := dmm.Bridge.GetPortalByMXID(msg.RoomID) - if portal == nil { - log.Warn().Stringer("event_id", msg.EventID).Stringer("room_id", msg.RoomID).Msg("Failed to redact message: portal not found") - err = msg.Delete(ctx) - if err != nil { - log.Err(err). - Stringer("event_id", msg.EventID). - Msg("Failed to delete disappearing message row in database") - } - continue - } - _, err = portal.MainIntent().RedactEvent(ctx, msg.RoomID, msg.EventID, mautrix.ReqRedact{ - Reason: "Message expired", - TxnID: fmt.Sprintf("mxsg_disappear_%s", msg.EventID), - }) - if err != nil { - log.Err(err). - Stringer("event_id", msg.EventID). - Stringer("room_id", msg.RoomID). - Msg("Failed to redact message") - } else { - log.Err(err). - Stringer("event_id", msg.EventID). - Stringer("room_id", msg.RoomID). - Msg("Redacted message") - } - err = msg.Delete(ctx) - if err != nil { - log.Err(err). - Stringer("event_id", msg.EventID). - Msg("Failed to delete disappearing message row in database") - } - } -} - -func (dmm *DisappearingMessagesManager) AddDisappearingMessage(ctx context.Context, eventID id.EventID, roomID id.RoomID, expireIn time.Duration, startTimerNow bool) { - if expireIn == 0 { - return - } - var expireAt time.Time - if startTimerNow { - expireAt = time.Now().Add(expireIn) - } - disappearingMessage := dmm.DB.DisappearingMessage.NewWithValues(roomID, eventID, expireIn, expireAt) - err := disappearingMessage.Insert(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("event_id", eventID). - Msg("Failed to add disappearing message to database") - return - } - zerolog.Ctx(ctx).Debug().Stringer("event_id", eventID). - Msg("Added disappearing message row to database") - if startTimerNow { - // Tell the disappearing messages loop to check again - dmm.checkMessagesChan <- struct{}{} - } -} diff --git a/docker-run.sh b/docker-run.sh index fdb22b2..5f1ec65 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -17,11 +17,7 @@ function fixperms { } if [[ ! -f /data/config.yaml ]]; then - if [[ "$BRIDGEV2" == "1" ]]; then - $BINARY_NAME -c /data/config.yaml -e - else - cp /opt/mautrix-signal/example-config.yaml /data/config.yaml - fi + $BINARY_NAME -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 93a7955..0000000 --- a/example-config.yaml +++ /dev/null @@ -1,317 +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 Signal 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:29328 - - # The hostname and port where this appservice should listen. - hostname: 0.0.0.0 - port: 29328 - - # 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: signal - # Appservice bot details. - bot: - # Username of the appservice bot. - username: signalbot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - displayname: Signal bridge bot - avatar: mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp - - # 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" - -# 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:8000 - -signal: - # Default device name that shows up in the Signal app. - device_name: mautrix-signal - -# Bridge config -bridge: - # Localpart template of MXIDs for Signal users. - # {{.}} is replaced with the internal ID of the Signal user. - username_template: signal_{{.}} - # Displayname template for Signal users. This is also used as the room name in DMs if private_chat_portal_meta is enabled. - # {{.ProfileName}} - The Signal profile name set by the user. - # {{.ContactName}} - The name for the user from your phone's contact list. This is not safe on multi-user instances. - # {{.PhoneNumber}} - The phone number of the user. - # {{.UUID}} - The UUID of the Signal user. - # {{.AboutEmoji}} - The emoji set by the user in their profile. - displayname_template: '{{or .ProfileName .PhoneNumber "Unknown user"}}' - # 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 avatars from the user's contact list be used? This is not safe on multi-user instances. - use_contact_avatars: false - # Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances. - use_outdated_profiles: false - # Should the Signal user's phone number be included in the room topic in private chat portal rooms? - number_in_topic: true - # Avatar image for the Note to Self room. - note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL - - portal_message_buffer: 128 - - # 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 `!signal sync-space` to create and fill the space for the first time. - personal_filtering_spaces: false - # Should Matrix m.notice-type messages be bridged? - bridge_notices: true - # Should the bridge send a read receipt from the bridge bot when a message has been sent to Signal? - 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 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 - # 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 - # Whether or not to make portals of groups that don't need approval of an admin to join by invite - # link publicly joinable on Matrix. - public_portals: false - # Send captions in the same message as images. This will send data compatible with both MSC2530. - # This is currently not supported in most clients. - caption_in_message: false - # Format for generating URLs from location messages for sending to Signal - # Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s' - # OpenStreetMap: 'https://www.openstreetmap.org/?mlat=%[1]s&mlon=%[2]s' - location_format: 'https://www.google.com/maps/place/%[1]s,%[2]s' - # Whether or not created rooms should have federation enabled. - # If false, created portal rooms will never be federated. - federate_rooms: true - # 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 - - # 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: '!signal' - # 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 Signal 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 - # 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 Signal 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 - # Should leaving the room on Matrix make the user leave on Signal? - bridge_matrix_leave: true - # 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 Signal 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, `!signal 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 Signal 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-signal.log - max_size: 100 - max_backups: 10 - compress: true diff --git a/go.mod b/go.mod index a04e88a..45187a6 100644 --- a/go.mod +++ b/go.mod @@ -48,3 +48,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) + +//replace maunium.net/go/mautrix => ../mautrix-go +//replace go.mau.fi/util => ../../Go/go-util diff --git a/legacyprovision/types.go b/legacyprovision/types.go deleted file mode 100644 index 72f393a..0000000 --- a/legacyprovision/types.go +++ /dev/null @@ -1,92 +0,0 @@ -// mautrix-signal - A Matrix-Signal 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 istributed 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 legacyprovision - -import ( - "encoding/json" - "net/http" - - "maunium.net/go/mautrix/id" -) - -func JSONResponse(w http.ResponseWriter, status int, response any) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(response) -} - -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"` - - // For response in LinkNew - SessionID string `json:"session_id,omitempty"` - URI string `json:"uri,omitempty"` - - // For response in LinkWaitForAccount - UUID string `json:"uuid,omitempty"` - Number string `json:"number,omitempty"` - - // For response in ResolveIdentifier - *ResolveIdentifierResponse -} - -type WhoAmIResponse struct { - Permissions int `json:"permissions"` - MXID string `json:"mxid"` - Signal *WhoAmIResponseSignal `json:"signal,omitempty"` -} - -type WhoAmIResponseSignal struct { - Number string `json:"number"` - UUID string `json:"uuid"` - Name string `json:"name"` - Ok bool `json:"ok"` -} - -type ResolveIdentifierResponse struct { - RoomID id.RoomID `json:"room_id"` - ChatID ResolveIdentifierResponseChatID `json:"chat_id"` - JustCreated bool `json:"just_created"` - OtherUser *ResolveIdentifierResponseOtherUser `json:"other_user,omitempty"` -} - -type ResolveIdentifierResponseChatID struct { - UUID string `json:"uuid"` - Number string `json:"number"` -} - -type ResolveIdentifierResponseOtherUser struct { - MXID id.UserID `json:"mxid"` - DisplayName string `json:"displayname"` - AvatarURL id.ContentURI `json:"avatar_url"` -} - -type LinkWaitForScanRequest struct { - SessionID string `json:"session_id"` -} - -type LinkWaitForAccountRequest struct { - SessionID string `json:"session_id"` - DeviceName string `json:"device_name"` // TODO this seems to not be used anywhere -} diff --git a/main.go b/main.go deleted file mode 100644 index eb24aa0..0000000 --- a/main.go +++ /dev/null @@ -1,352 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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" - "fmt" - "os" - "sync" - - "github.com/google/uuid" - "github.com/rs/zerolog" - "go.mau.fi/util/configupgrade" - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/config" - "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - "go.mau.fi/mautrix-signal/pkg/signalmeow/store" -) - -//go:embed example-config.yaml -var ExampleConfig string - -// 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" -) - -type SignalBridge struct { - bridge.Bridge - - Config *config.Config - DB *database.Database - Metrics *MetricsHandler - MeowStore *store.Container - - provisioning *ProvisioningAPI - - usersByMXID map[id.UserID]*User - usersBySignalID map[uuid.UUID]*User - usersLock sync.Mutex - - managementRooms map[id.RoomID]*User - managementRoomsLock sync.Mutex - - portalsByMXID map[id.RoomID]*Portal - portalsByID map[database.PortalKey]*Portal - portalsLock sync.Mutex - - puppets map[uuid.UUID]*Puppet - puppetsByCustomMXID map[id.UserID]*Puppet - puppetsLock sync.Mutex - - disappearingMessagesManager *DisappearingMessagesManager -} - -var _ bridge.ChildOverride = (*SignalBridge)(nil) - -func (br *SignalBridge) GetExampleConfig() string { - return ExampleConfig -} - -func (br *SignalBridge) GetConfigPtr() interface{} { - br.Config = &config.Config{ - BaseConfig: &br.Bridge.Config, - } - br.Config.BaseConfig.Bridge = &br.Config.Bridge - return br.Config -} - -func (br *SignalBridge) Init() { - br.CommandProcessor = commands.NewProcessor(&br.Bridge) - br.RegisterCommands() - - signalmeow.SetLogger(br.ZLog.With().Str("component", "signalmeow").Logger()) - - br.DB = database.New(br.Bridge.DB) - br.MeowStore = store.NewStore(br.Bridge.DB, dbutil.ZeroLogger(br.ZLog.With().Str("db_section", "signalmeow").Logger())) - - 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.disappearingMessagesManager = &DisappearingMessagesManager{ - DB: br.DB, - Log: br.ZLog.With().Str("component", "disappearing messages").Logger(), - Bridge: br, - } - - br.Metrics = NewMetricsHandler(br.Config.Metrics.Listen, br.ZLog.With().Str("component", "metrics").Logger(), br.DB) - br.MatrixHandler.TrackEventDuration = br.Metrics.TrackMatrixEvent - - signalFormatParams = &signalfmt.FormatParams{ - GetUserInfo: func(ctx context.Context, u uuid.UUID) signalfmt.UserInfo { - puppet := br.GetPuppetBySignalID(u) - if puppet == nil { - return signalfmt.UserInfo{} - } - user := br.GetUserBySignalID(u) - if user != nil { - return signalfmt.UserInfo{ - MXID: user.MXID, - Name: puppet.Name, - } - } - return signalfmt.UserInfo{ - MXID: puppet.MXID, - Name: puppet.Name, - } - }, - } - matrixFormatParams = &matrixfmt.HTMLParser{ - GetUUIDFromMXID: func(ctx context.Context, userID id.UserID) uuid.UUID { - parsed, ok := br.ParsePuppetMXID(userID) - if ok { - return parsed - } - user := br.GetUserByMXIDIfExists(userID) - if user != nil && user.SignalID != uuid.Nil { - return user.SignalID - } - return uuid.Nil - }, - } -} - -func (br *SignalBridge) logLostPortals(ctx context.Context) { - exists, err := br.DB.TableExists(ctx, "lost_portals") - if err != nil { - br.ZLog.Err(err).Msg("Failed to check if lost_portals table exists") - } else if !exists { - return - } - lostPortals, err := br.DB.LostPortal.GetAll(ctx) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get lost portals") - return - } else if len(lostPortals) == 0 { - return - } - lostCountByReceiver := make(map[string]int) - for _, lost := range lostPortals { - lostCountByReceiver[lost.Receiver]++ - } - br.ZLog.Warn(). - Any("count_by_receiver", lostCountByReceiver). - Msg("Some portals were discarded due to the receiver not being logged into the bridge anymore. " + - "Use `!signal cleanup-lost-portals` to remove them from the database. " + - "Alternatively, you can re-insert the data into the portal table with the appropriate receiver column to restore the portals.") -} - -func (br *SignalBridge) Start() { - go br.logLostPortals(context.TODO()) - err := br.MeowStore.Upgrade(context.TODO()) - if err != nil { - br.ZLog.Fatal().Err(err).Msg("Failed to upgrade signalmeow database") - os.Exit(15) - } - if br.provisioning != nil { - br.ZLog.Debug().Msg("Initializing provisioning API") - br.provisioning.Init() - } - go br.StartUsers() - if br.Config.Metrics.Enabled { - go br.Metrics.Start() - } - go br.disappearingMessagesManager.StartDisappearingLoop(context.TODO()) -} - -func (br *SignalBridge) Stop() { - br.Metrics.Stop() - for _, user := range br.usersByMXID { - br.ZLog.Debug().Stringer("user_id", user.MXID).Msg("Disconnecting user") - user.Disconnect() - } -} - -func (br *SignalBridge) GetIPortal(mxid id.RoomID) bridge.Portal { - p := br.GetPortalByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *SignalBridge) GetIUser(mxid id.UserID, create bool) bridge.User { - p := br.GetUserByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *SignalBridge) IsGhost(mxid id.UserID) bool { - _, isGhost := br.ParsePuppetMXID(mxid) - return isGhost -} - -func (br *SignalBridge) GetIGhost(mxid id.UserID) bridge.Ghost { - p := br.GetPuppetByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *SignalBridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) { - inviter := brInviter.(*User) - puppet := brGhost.(*Puppet) - - log := br.ZLog.With(). - Str("action", "create private portal"). - Stringer("target_room_id", roomID). - Stringer("inviter_mxid", brInviter.GetMXID()). - Stringer("invitee_uuid", puppet.SignalID). - Logger() - log.Debug().Msg("Creating private chat portal") - - key := database.NewPortalKey(puppet.SignalID.String(), inviter.SignalID) - portal := br.GetPortalByChatID(key) - ctx := log.WithContext(context.TODO()) - - if len(portal.MXID) == 0 { - br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal) - return - } - log.Debug(). - Stringer("existing_room_id", portal.MXID). - Msg("Existing private chat portal found, trying to invite user") - - 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 [%[1]s](https://matrix.to/#/%[1]s)", portal.MXID) - errorContent := format.RenderMarkdown(errorMessage, true, false) - _, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent) - log.Debug().Msg("Leaving ghost from private chat room after accepting invite because we already have a chat with the user") - _, _ = intent.LeaveRoom(ctx, roomID) -} - -func (br *SignalBridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) { - log := zerolog.Ctx(ctx) - log.Debug().Msg("Creating private portal from invite") - - // 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 in private chat room") - } else { - encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1 - } - portal.MXID = roomID - br.portalsLock.Lock() - br.portalsByMXID[portal.MXID] = portal - br.portalsLock.Unlock() - intent := puppet.DefaultIntent() - - if br.Config.Bridge.Encryption.Default || encryptionEnabled { - log.Debug().Msg("Adding bridge bot to new private chat portal as encryption is enabled") - _, 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.UpdateDMInfo(ctx, true) - _, _ = intent.SendNotice(ctx, roomID, "Private chat portal created") - log.Info().Msg("Created private chat portal after invite") -} - -func main() { - br := &SignalBridge{ - usersByMXID: make(map[id.UserID]*User), - usersBySignalID: make(map[uuid.UUID]*User), - - managementRooms: make(map[id.RoomID]*User), - - portalsByMXID: make(map[id.RoomID]*Portal), - portalsByID: make(map[database.PortalKey]*Portal), - - puppets: make(map[uuid.UUID]*Puppet), - puppetsByCustomMXID: make(map[id.UserID]*Puppet), - } - br.Bridge = bridge.Bridge{ - Name: "mautrix-signal", - URL: "https://github.com/mautrix/signal", - Description: "A Matrix-Signal puppeting bridge.", - Version: "0.6.3", - ProtocolName: "Signal", - BeeperServiceName: "signal", - BeeperNetworkName: "signal", - - CryptoPickleKey: "mautrix.bridge.e2ee", - - ConfigUpgrader: &configupgrade.StructUpgrader{ - SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), - Blocks: config.SpacedBlocks, - Base: ExampleConfig, - }, - - Child: br, - } - br.InitVersion(Tag, Commit, BuildTime) - - br.Main() -} diff --git a/messagetracking.go b/messagetracking.go deleted file mode 100644 index e95abb4..0000000 --- a/messagetracking.go +++ /dev/null @@ -1,311 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/msgconv" -) - -var ( - errUserNotConnected = errors.New("you are not connected to Signal") - 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") - errCantRelayReactions = errors.New("user is not logged in and reactions can't be relayed") - errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") - errUnexpectedParsedContentType = errors.New("unexpected parsed content type") - - errRedactionTargetNotFound = errors.New("redaction target message was not found") - errRedactionTargetSentBySomeoneElse = errors.New("redaction target message was sent by someone else") - errUnreactTargetSentBySomeoneElse = errors.New("redaction target reaction was sent by someone else") - errReactionTargetNotFound = errors.New("reaction target message not found") - errEditUnknownTarget = errors.New("unknown edit target message") - errFailedToGetEditTarget = errors.New("failed to get edit target message") - errEditDifferentSender = errors.New("can't edit message sent by another user") - errEditTooOld = errors.New("message is too old to be edited") - - 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, errUnexpectedParsedContentType), - errors.Is(err, msgconv.ErrUnsupportedMsgType), - errors.Is(err, msgconv.ErrInvalidGeoURI): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "" - case errors.Is(err, errMNoticeDisabled): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, "" - case errors.Is(err, errEditDifferentSender), - errors.Is(err, errEditTooOld), - errors.Is(err, errEditUnknownTarget): - 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, errRedactionTargetNotFound), - errors.Is(err, errReactionTargetNotFound), - errors.Is(err, errRedactionTargetSentBySomeoneElse), - errors.Is(err, errUnreactTargetSentBySomeoneElse): - return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "" - case 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, "" - 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 { - portal.log.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 { - portal.log.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 { - portal.log.Debug().Err(err).Stringer("event_id", eventID).Msg("Failed to send delivery receipt") - } - } -} - -func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) { - log := portal.log.With(). - Str("handling_step", part). - Str("event_type", evt.Type.String()). - Stringer("event_id", evt.ID). - Stringer("sender", evt.Sender). - Logger() - if evt.Type == event.EventRedaction { - log = log.With().Stringer("redacts", evt.Redacts).Logger() - } - ctx = log.WithContext(ctx) - - origEvtID := evt.ID - if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { - origEvtID = retryMeta.OriginalEventID - } - if err != nil { - logEvt := log.Error() - if part == "Ignoring" { - logEvt = log.Debug() - } - logEvt.Err(err).Msg("Sending message metrics for 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 { - log.Debug().Msg("Sending metrics for 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 { - log.Debug().Object("timings", ms.timings).Msg("Timings for event") - } -} - -type messageTimings struct { - initReceive time.Duration - decrypt time.Duration - implicitRR time.Duration - portalQueue time.Duration - totalReceive time.Duration - - preproc time.Duration - convert time.Duration - totalSend time.Duration -} - -func niceRound(dur time.Duration) time.Duration { - switch { - case dur < time.Millisecond: - return dur - case dur < time.Second: - return dur.Round(100 * time.Microsecond) - default: - return dur.Round(time.Millisecond) - } -} - -func (mt *messageTimings) MarshalZerologObject(evt *zerolog.Event) { - evt. - Dict("bridge", zerolog.Dict(). - Stringer("init_receive", niceRound(mt.initReceive)). - Stringer("decrypt", niceRound(mt.decrypt)). - Stringer("queue", niceRound(mt.portalQueue)). - Stringer("total_hs_to_portal", niceRound(mt.totalReceive))). - Dict("portal", zerolog.Dict(). - Stringer("implicit_rr", niceRound(mt.implicitRR)). - Stringer("preproc", niceRound(mt.preproc)). - Stringer("convert", niceRound(mt.convert)). - Stringer("total_send", niceRound(mt.totalSend))) -} - -type metricSender struct { - portal *Portal - previousNotice id.EventID - lock sync.Mutex - completed bool - retryNum int - timings *messageTimings - ctx context.Context -} - -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(evt *event.Event, err error, part string, completed bool) { - ms.lock.Lock() - defer ms.lock.Unlock() - if !completed && ms.completed { - return - } - ms.portal.sendMessageMetrics(ms.ctx, evt, err, part, ms) - ms.retryNum++ - ms.completed = completed -} diff --git a/metrics.go b/metrics.go deleted file mode 100644 index 2e76a04..0000000 --- a/metrics.go +++ /dev/null @@ -1,281 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Element -// -// 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" - "net/http" - "runtime/debug" - "sync" - "time" - - "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/rs/zerolog" - "maunium.net/go/mautrix/event" - - "go.mau.fi/mautrix-signal/database" -) - -type MetricsHandler struct { - db *database.Database - server *http.Server - log zerolog.Logger - - running bool - ctx context.Context - stopRecorder func() - - matrixEventHandling *prometheus.HistogramVec - signalMessageAge prometheus.Histogram - signalMessageHandling *prometheus.HistogramVec - countCollection prometheus.Histogram - 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[uuid.UUID]bool - connectedStateLock sync.Mutex - loggedIn prometheus.Gauge - loggedInState map[uuid.UUID]bool - loggedInStateLock sync.Mutex -} - -func NewMetricsHandler(address string, log zerolog.Logger, db *database.Database) *MetricsHandler { - portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "signal_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"}), - signalMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{ - Name: "remote_event_age", - Help: "Age of messages received from Signal", - Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60}, - }), - signalMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "remote_event", - Help: "Time spent processing Signal messages", - }, []string{"message_type"}), - countCollection: promauto.NewHistogram(prometheus.HistogramOpts{ - Name: "signal_count_collection", - Help: "Time spent collecting the bridge_*_total metrics", - }), - puppetCount: promauto.NewGauge(prometheus.GaugeOpts{ - Name: "signal_puppets_total", - Help: "Number of Signal users bridged into Matrix", - }), - userCount: promauto.NewGauge(prometheus.GaugeOpts{ - Name: "signal_users_total", - Help: "Number of Matrix users using the bridge", - }), - messageCount: promauto.NewGauge(prometheus.GaugeOpts{ - Name: "signal_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: "Bridge users logged into Signal", - }), - loggedInState: make(map[uuid.UUID]bool), - connected: promauto.NewGauge(prometheus.GaugeOpts{ - Name: "bridge_connected", - Help: "Bridge users connected to Signal", - }), - connectedState: make(map[uuid.UUID]bool), - } -} - -func noop() {} - -func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() { - if !mh.running { - return noop - } - start := time.Now() - return func() { - duration := time.Since(start) - mh.matrixEventHandling. - With(prometheus.Labels{"event_type": eventType.Type}). - Observe(duration.Seconds()) - } -} - -func (mh *MetricsHandler) TrackSignalMessage(timestamp time.Time, messageType string) func() { - if !mh.running { - return noop - } - - start := time.Now() - return func() { - duration := time.Since(start) - mh.signalMessageHandling. - With(prometheus.Labels{"message_type": messageType}). - Observe(duration.Seconds()) - mh.signalMessageAge.Observe(time.Since(timestamp).Seconds()) - } -} - -func (mh *MetricsHandler) TrackLoginState(signalID uuid.UUID, loggedIn bool) { - if !mh.running { - return - } - mh.loggedInStateLock.Lock() - defer mh.loggedInStateLock.Unlock() - currentVal, ok := mh.loggedInState[signalID] - if !ok || currentVal != loggedIn { - mh.loggedInState[signalID] = loggedIn - if loggedIn { - mh.loggedIn.Inc() - } else if ok { - mh.loggedIn.Dec() - } - } -} - -func (mh *MetricsHandler) TrackConnectionState(signalID uuid.UUID, connected bool) { - if !mh.running { - return - } - mh.connectedStateLock.Lock() - defer mh.connectedStateLock.Unlock() - currentVal, ok := mh.connectedState[signalID] - if !ok || currentVal != connected { - mh.connectedState[signalID] = connected - if connected { - mh.connected.Inc() - } else if ok { - 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.Warn().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.Warn().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.Warn().Err(err).Msg("Failed to scan number of messages") - } else { - mh.messageCount.Set(float64(messageCount)) - } - - var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int - // TODO Use a more precise way to check if a chat_id is a UUID. - // It should also be compatible with both SQLite & Postgres. - err = mh.db.QueryRow(mh.ctx, ` - SELECT - COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_group_portals, - COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_private_portals, - COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals, - COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals - FROM portal WHERE mxid<>'' - `).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount) - if err != nil { - mh.log.Warn().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.Since(start).Seconds()) -} - -func (mh *MetricsHandler) startUpdatingStats() { - defer func() { - r := recover() - if r != nil { - evt := mh.log.Fatal().Str("stack", string(debug.Stack())) - if err, ok := r.(error); ok { - evt = evt.Err(err) - } else { - evt = evt.Any("error", r) - } - evt.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 && err != http.ErrServerClosed { - mh.log.Fatal().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("Error closing metrics listener") - } -} diff --git a/msgconv/msgconv.go b/msgconv/msgconv.go deleted file mode 100644 index ee3ff55..0000000 --- a/msgconv/msgconv.go +++ /dev/null @@ -1,65 +0,0 @@ -// mautrix-signal - A Matrix-signal 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 msgconv - -import ( - "context" - "time" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" -) - -type PortalMethods interface { - UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) - DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) - GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID) - GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote - - GetClient(ctx context.Context) *signalmeow.Client - - GetData(ctx context.Context) *database.Portal -} - -type ExtendedPortalMethods interface { - QueueFileTransfer(ctx context.Context, msgTS uint64, fileName string, ap *signalpb.AttachmentPointer) (id.ContentURIString, error) -} - -type MessageConverter struct { - PortalMethods - - SignalFmtParams *signalfmt.FormatParams - MatrixFmtParams *matrixfmt.HTMLParser - - ConvertVoiceMessages bool - ConvertGIFToAPNG bool - MaxFileSize int64 - AsyncFiles bool - UpdateDisappearing func(ctx context.Context, newTimer time.Duration) - - LocationFormat string -} - -func (mc *MessageConverter) IsPrivateChat(ctx context.Context) bool { - return !mc.GetData(ctx).UserID().IsEmpty() -} diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 732b0ae..a5d99c9 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -32,6 +32,7 @@ import ( "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-signal/pkg/libsignalgo" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow/types" ) @@ -39,7 +40,7 @@ const PrivateChatTopic = "Signal private chat" const NoteToSelfName = "Signal Note to Self" func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { - userID, err := parseUserID(ghost.ID) + userID, err := signalid.ParseUserID(ghost.ID) if err != nil { return nil, err } @@ -51,7 +52,7 @@ func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) ( } func (s *SignalClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - userID, groupID, err := parsePortalID(portal.ID) + userID, groupID, err := signalid.ParsePortalID(portal.ID) if err != nil { return nil, err } @@ -143,19 +144,19 @@ func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, cre // createChat is a no-op: chats don't need to be created, and we always return chat info if aci != uuid.Nil { - ghost, err := s.Main.Bridge.GetGhostByID(ctx, makeUserID(aci)) + ghost, err := s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(aci)) if err != nil { return nil, fmt.Errorf("failed to get ghost: %w", err) } return &bridgev2.ResolveIdentifierResponse{ - UserID: makeUserID(aci), + UserID: signalid.MakeUserID(aci), UserInfo: s.contactToUserInfo(recipient), Ghost: ghost, Chat: s.makeCreateDMResponse(recipient), }, nil } else { return &bridgev2.ResolveIdentifierResponse{ - UserID: makeUserIDFromServiceID(libsignalgo.NewPNIServiceID(pni)), + UserID: signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(pni)), UserInfo: s.contactToUserInfo(recipient), Chat: s.makeCreateDMResponse(recipient), }, nil @@ -179,14 +180,14 @@ func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveI Chat: s.makeCreateDMResponse(recipient), } if recipient.ACI != uuid.Nil { - recipientResp.UserID = makeUserID(recipient.ACI) + recipientResp.UserID = signalid.MakeUserID(recipient.ACI) ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID) if err != nil { return nil, fmt.Errorf("failed to get ghost for %s: %w", recipient.ACI, err) } recipientResp.Ghost = ghost } else { - recipientResp.UserID = makeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI)) + recipientResp.UserID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI)) } resp[i] = recipientResp } @@ -215,7 +216,7 @@ func (s *SignalClient) makeCreateDMResponse(recipient *types.Recipient) *bridgev name = s.Main.Config.FormatDisplayname(recipient) serviceID = libsignalgo.NewPNIServiceID(recipient.PNI) } else { - members.OtherUserID = makeUserID(recipient.ACI) + members.OtherUserID = signalid.MakeUserID(recipient.ACI) if recipient.ACI == s.Client.Store.ACI { name = NoteToSelfName avatar = &bridgev2.Avatar{ diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 838c0ee..56ab59a 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -11,6 +11,7 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow" ) @@ -112,7 +113,7 @@ func (s *SignalClient) IsThisUser(_ context.Context, userID networkid.UserID) bo if s.Client == nil { return false } - return userID == makeUserID(s.Client.Store.ACI) + return userID == signalid.MakeUserID(s.Client.Store.ACI) } func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnectionStatus) { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 4c36674..6a4e4dc 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -20,19 +20,12 @@ import ( "context" "fmt" "text/template" - "time" "github.com/google/uuid" - "github.com/rs/zerolog" "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-signal/msgconv" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv" "go.mau.fi/mautrix-signal/pkg/signalmeow" "go.mau.fi/mautrix-signal/pkg/signalmeow/store" ) @@ -82,65 +75,7 @@ func (s *SignalConnector) Init(bridge *bridgev2.Bridge) { } s.Store = store.NewStore(bridge.DB.Database, dbutil.ZeroLogger(bridge.Log.With().Str("db_section", "signalmeow").Logger())) s.Bridge = bridge - s.MsgConv = &msgconv.MessageConverter{ - PortalMethods: &msgconvPortalMethods{}, - SignalFmtParams: &signalfmt.FormatParams{ - GetUserInfo: func(ctx context.Context, uuid uuid.UUID) signalfmt.UserInfo { - ghost, err := s.Bridge.GetGhostByID(ctx, makeUserID(uuid)) - if err != nil { - // TODO log? - return signalfmt.UserInfo{} - } - userInfo := signalfmt.UserInfo{ - MXID: ghost.Intent.GetMXID(), - Name: ghost.Name, - } - userLogin := s.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(uuid.String())) - if userLogin != nil { - userInfo.MXID = userLogin.UserMXID - // TODO find matrix user displayname? - } - return userInfo - }, - }, - MatrixFmtParams: &matrixfmt.HTMLParser{ - GetUUIDFromMXID: func(ctx context.Context, userID id.UserID) uuid.UUID { - parsed, ok := s.Bridge.Matrix.ParseGhostMXID(userID) - if ok { - u, _ := uuid.Parse(string(parsed)) - return u - } - user, _ := s.Bridge.GetExistingUserByMXID(ctx, userID) - // TODO log errors? - if user != nil { - preferredLogin, _, _ := ctx.Value(msgconvContextKey).(*msgconvContext).Portal.FindPreferredLogin(ctx, user, true) - if preferredLogin != nil { - u, _ := uuid.Parse(string(preferredLogin.ID)) - return u - } - } - return uuid.Nil - }, - }, - ConvertVoiceMessages: true, - ConvertGIFToAPNG: true, - MaxFileSize: 50 * 1024 * 1024, - AsyncFiles: true, - LocationFormat: s.Config.LocationFormat, - UpdateDisappearing: func(ctx context.Context, newTimer time.Duration) { - portal := ctx.Value(msgconvContextKey).(*msgconvContext).Portal - portal.Disappear.Timer = newTimer - if newTimer == 0 { - portal.Disappear.Type = "" - } else { - portal.Disappear.Type = database.DisappearingTypeAfterRead - } - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database") - } - }, - } + s.MsgConv = msgconv.NewMessageConverter(bridge, s.Config.LocationFormat) } func (s *SignalConnector) SetMaxFileSize(maxSize int64) { diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 68e21d5..14f59f2 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -18,26 +18,20 @@ package connector import ( "maunium.net/go/mautrix/bridgev2/database" + + "go.mau.fi/mautrix-signal/pkg/signalid" ) func (s *SignalConnector) GetDBMetaTypes() database.MetaTypes { return database.MetaTypes{ Portal: func() any { - return &PortalMetadata{} + return &signalid.PortalMetadata{} }, Ghost: nil, Message: func() any { - return &MessageMetadata{} + return &signalid.MessageMetadata{} }, Reaction: nil, UserLogin: nil, } } - -type PortalMetadata struct { - Revision uint32 `json:"revision"` -} - -type MessageMetadata struct { - ContainsAttachments bool `json:"contains_attachments,omitempty"` -} diff --git a/pkg/connector/groupinfo.go b/pkg/connector/groupinfo.go index 6bea361..3bf809d 100644 --- a/pkg/connector/groupinfo.go +++ b/pkg/connector/groupinfo.go @@ -27,6 +27,7 @@ import ( "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-signal/pkg/libsignalgo" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow" "go.mau.fi/mautrix-signal/pkg/signalmeow/types" ) @@ -174,7 +175,7 @@ func (s *SignalClient) makeGroupAvatar(meta signalmeow.GroupAvatarMeta) *bridgev func makeRevisionUpdater(rev uint32) func(ctx context.Context, portal *bridgev2.Portal) bool { return func(ctx context.Context, portal *bridgev2.Portal) bool { - meta := portal.Metadata.(*PortalMetadata) + meta := portal.Metadata.(*signalid.PortalMetadata) if meta.Revision < rev { meta.Revision = rev return true diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 2f36ae7..9e8c353 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -32,12 +32,13 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-signal/pkg/libsignalgo" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error { - userID, groupID, err := parsePortalID(portalID) + userID, groupID, err := signalid.ParsePortalID(portalID) if err != nil { return err } @@ -77,15 +78,7 @@ func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.Porta } func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { - mcCtx := &msgconvContext{ - Connector: s.Main, - Intent: nil, - Client: s, - Portal: msg.Portal, - ReplyTo: msg.ReplyTo, - } - ctx = context.WithValue(ctx, msgconvContextKey, mcCtx) - converted, err := s.Main.MsgConv.ToSignal(ctx, msg.Event, msg.Content, msg.OrigSender != nil) + converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo) if err != nil { return nil, err } @@ -94,10 +87,10 @@ func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma return nil, err } dbMsg := &database.Message{ - ID: makeMessageID(s.Client.Store.ACI, converted.GetTimestamp()), - SenderID: makeUserID(s.Client.Store.ACI), + ID: signalid.MakeMessageID(s.Client.Store.ACI, converted.GetTimestamp()), + SenderID: signalid.MakeUserID(s.Client.Store.ACI), Timestamp: time.UnixMilli(int64(converted.GetTimestamp())), - Metadata: &MessageMetadata{ + Metadata: &signalid.MessageMetadata{ ContainsAttachments: len(converted.Attachments) > 0, }, } @@ -107,27 +100,20 @@ func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma } func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { - _, targetSentTimestamp, err := parseMessageID(msg.EditTarget.ID) + _, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID) if err != nil { return fmt.Errorf("failed to parse target message ID: %w", err) - } else if msg.EditTarget.SenderID != makeUserID(s.Client.Store.ACI) { + } else if msg.EditTarget.SenderID != signalid.MakeUserID(s.Client.Store.ACI) { return fmt.Errorf("cannot edit other people's messages") } - mcCtx := &msgconvContext{ - Connector: s.Main, - Intent: nil, - Client: s, - Portal: msg.Portal, - } + var replyTo *database.Message if msg.EditTarget.ReplyTo.MessageID != "" { - var err error - mcCtx.ReplyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo) + replyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo) if err != nil { return fmt.Errorf("failed to get message reply target: %w", err) } } - ctx = context.WithValue(ctx, msgconvContextKey, mcCtx) - converted, err := s.Main.MsgConv.ToSignal(ctx, msg.Event, msg.Content, msg.OrigSender != nil) + converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo) if err != nil { return err } @@ -138,22 +124,22 @@ func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matri if err != nil { return err } - msg.EditTarget.ID = makeMessageID(s.Client.Store.ACI, converted.GetTimestamp()) - msg.EditTarget.Metadata = &MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0} + msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, converted.GetTimestamp()) + msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0} msg.EditTarget.EditCount++ return nil } func (s *SignalClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { return bridgev2.MatrixReactionPreResponse{ - SenderID: makeUserID(s.Client.Store.ACI), + SenderID: signalid.MakeUserID(s.Client.Store.ACI), EmojiID: "", Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key), }, nil } func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { - targetAuthorACI, targetSentTimestamp, err := parseMessageID(msg.TargetMessage.ID) + targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID) if err != nil { return nil, fmt.Errorf("failed to parse target message ID: %w", err) } @@ -177,7 +163,7 @@ func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.M } func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { - targetAuthorACI, targetSentTimestamp, err := parseMessageID(msg.TargetReaction.MessageID) + targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetReaction.MessageID) if err != nil { return fmt.Errorf("failed to parse target message ID: %w", err) } @@ -201,10 +187,10 @@ func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid } func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { - _, targetSentTimestamp, err := parseMessageID(msg.TargetMessage.ID) + _, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID) if err != nil { return fmt.Errorf("failed to parse target message ID: %w", err) - } else if msg.TargetMessage.SenderID != makeUserID(s.Client.Store.ACI) { + } else if msg.TargetMessage.SenderID != signalid.MakeUserID(s.Client.Store.ACI) { return fmt.Errorf("cannot delete other people's messages") } wrappedContent := &signalpb.Content{ @@ -237,7 +223,7 @@ func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bri } messagesToRead := map[uuid.UUID][]uint64{} for _, msg := range dbMessages { - userID, timestamp, err := parseMessageID(msg.ID) + userID, timestamp, err := signalid.ParseMessageID(msg.ID) if err != nil { return fmt.Errorf("failed to parse message ID %q: %w", msg.ID, err) } @@ -275,7 +261,7 @@ func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bri } func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2.MatrixTyping) error { - userID, _, err := parsePortalID(typing.Portal.ID) + userID, _, err := signalid.ParsePortalID(typing.Portal.ID) if err != nil { return err } @@ -292,11 +278,11 @@ func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2. } func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev2.Portal, gc *signalmeow.GroupChange, postUpdatePortal func()) (bool, error) { - _, groupID, err := parsePortalID(portal.ID) + _, groupID, err := signalid.ParsePortalID(portal.ID) if err != nil || groupID == "" { return false, err } - gc.Revision = portal.Metadata.(*PortalMetadata).Revision + 1 + gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1 revision, err := s.Client.UpdateGroup(ctx, gc, groupID) if err != nil { return false, err @@ -316,7 +302,7 @@ func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev if postUpdatePortal != nil { postUpdatePortal() } - portal.Metadata.(*PortalMetadata).Revision = revision + portal.Metadata.(*signalid.PortalMetadata).Revision = revision return true, nil } @@ -327,7 +313,7 @@ func (s *SignalClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.M } func (s *SignalClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) { - _, groupID, err := parsePortalID(msg.Portal.ID) + _, groupID, err := signalid.ParsePortalID(msg.Portal.ID) if err != nil || groupID == "" { return false, err } diff --git a/pkg/connector/handlesignal.go b/pkg/connector/handlesignal.go index 169338d..2f6803d 100644 --- a/pkg/connector/handlesignal.go +++ b/pkg/connector/handlesignal.go @@ -30,6 +30,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow/events" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) @@ -63,7 +64,7 @@ func (s *SignalClient) wrapCallEvent(evt *events.Call) bridgev2.RemoteMessage { PortalKey: s.makePortalKey(evt.Info.ChatID), Data: evt, CreatePortal: true, - ID: makeMessageID(evt.Info.Sender, evt.Timestamp), + ID: signalid.MakeMessageID(evt.Info.Sender, evt.Timestamp), Sender: s.makeEventSender(evt.Info.Sender), Timestamp: time.UnixMilli(int64(evt.Timestamp)), ConvertMessageFunc: convertCallEvent, @@ -76,7 +77,7 @@ func convertCallEvent(ctx context.Context, portal *bridgev2.Portal, intent bridg } if data.IsRinging { content.Body = "Incoming call" - if userID, _, _ := parsePortalID(portal.ID); !userID.IsEmpty() { + if userID, _, _ := signalid.ParsePortalID(portal.ID); !userID.IsEmpty() { content.MsgType = event.MsgText } } else { @@ -152,7 +153,7 @@ func (evt *Bv2ChatEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal) if !ok || dataMsg.GroupV2 == nil { return } - portalRev := portal.Metadata.(*PortalMetadata).Revision + portalRev := portal.Metadata.(*signalid.PortalMetadata).Revision if evt.Info.GroupRevision > portalRev { toRevision := evt.Info.GroupRevision if dataMsg.GetGroupV2().GetGroupChange() != nil { @@ -206,7 +207,7 @@ func (evt *Bv2ChatEvent) GetID() networkid.MessageID { if ts == 0 { panic(fmt.Errorf("GetID() called for non-DataMessage event")) } - return makeMessageID(evt.Info.Sender, ts) + return signalid.MakeMessageID(evt.Info.Sender, ts) } func (evt *Bv2ChatEvent) getDataMsgTimestamp() uint64 { @@ -251,7 +252,7 @@ func (evt *Bv2ChatEvent) GetTargetMessage() networkid.MessageID { if targetAuthorACI != "" { targetAuthorUUID, _ = uuid.Parse(targetAuthorACI) } - return makeMessageID(targetAuthorUUID, targetSentTS) + return signalid.MakeMessageID(targetAuthorUUID, targetSentTS) } func (evt *Bv2ChatEvent) GetReactionEmoji() (string, networkid.EmojiID) { @@ -267,82 +268,35 @@ func (evt *Bv2ChatEvent) GetRemovedEmojiID() networkid.EmojiID { } func (evt *Bv2ChatEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { - mcCtx := &msgconvContext{ - Connector: evt.s.Main, - Intent: intent, - Client: evt.s, - Portal: portal, - } - ctx = context.WithValue(ctx, msgconvContextKey, mcCtx) dataMsg, ok := evt.Event.(*signalpb.DataMessage) if !ok { return nil, fmt.Errorf("ConvertMessage() called for non-DataMessage event") } - converted := evt.s.Main.MsgConv.ToMatrix(ctx, dataMsg) - converted.MergeCaption() - var replyTo *networkid.MessageOptionalPartID - if dataMsg.GetQuote() != nil { - quoteAuthor, _ := uuid.Parse(dataMsg.Quote.GetAuthorAci()) - replyTo = &networkid.MessageOptionalPartID{ - MessageID: makeMessageID(quoteAuthor, dataMsg.Quote.GetId()), - } - } - convertedParts := make([]*bridgev2.ConvertedMessagePart, len(converted.Parts)) - for i, part := range converted.Parts { - convertedParts[i] = &bridgev2.ConvertedMessagePart{ - ID: makeMessagePartID(i), - Type: part.Type, - Content: part.Content, - Extra: part.Extra, - DBMetadata: &MessageMetadata{ContainsAttachments: len(dataMsg.GetAttachments()) > 0}, - } - } - var disappear database.DisappearingSetting - if converted.DisappearIn != 0 { - disappear = database.DisappearingSetting{ - Type: database.DisappearingTypeAfterRead, - Timer: time.Duration(converted.DisappearIn) * time.Second, - } - dataMsgTS := time.UnixMilli(int64(dataMsg.GetTimestamp())) + converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, intent, dataMsg) + if converted.Disappear.Type != "" { + evtTS := evt.GetTimestamp() + portal.UpdateDisappearingSetting(ctx, converted.Disappear, nil, evtTS, true, true) if evt.Info.Sender == evt.s.Client.Store.ACI { - disappear.DisappearAt = dataMsgTS.Add(disappear.Timer) + converted.Disappear.DisappearAt = evtTS.Add(converted.Disappear.Timer) } - portal.UpdateDisappearingSetting(ctx, disappear, nil, dataMsgTS, true, true) } - return &bridgev2.ConvertedMessage{ - ReplyTo: replyTo, - Parts: convertedParts, - Disappear: disappear, - }, nil + return converted, nil } func (evt *Bv2ChatEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) { - mcCtx := &msgconvContext{ - Connector: evt.s.Main, - Intent: intent, - Client: evt.s, - Portal: portal, - } - ctx = context.WithValue(ctx, msgconvContextKey, mcCtx) editMsg, ok := evt.Event.(*signalpb.EditMessage) if !ok { return nil, fmt.Errorf("ConvertEdit() called for non-EditMessage event") } // TODO tell converter about existing parts to avoid reupload? - converted := evt.s.Main.MsgConv.ToMatrix(ctx, editMsg.GetDataMessage()) - converted.MergeCaption() - convertedEdit := &bridgev2.ConvertedEdit{} + converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, intent, editMsg.GetDataMessage()) // TODO can anything other than the text be edited? - lastPart := converted.Parts[len(converted.Parts)-1] - convertedEdit.ModifiedParts = append(convertedEdit.ModifiedParts, &bridgev2.ConvertedEditPart{ - Part: existing[len(existing)-1], - Type: lastPart.Type, - Content: lastPart.Content, - Extra: lastPart.Extra, - }) - convertedEdit.ModifiedParts[0].Part.EditCount++ - convertedEdit.ModifiedParts[0].Part.ID = makeMessageID(evt.Info.Sender, editMsg.GetDataMessage().GetTimestamp()) - return convertedEdit, nil + editPart := converted.Parts[len(converted.Parts)-1].ToEditPart(existing[len(existing)-1]) + editPart.Part.EditCount++ + editPart.Part.ID = signalid.MakeMessageID(evt.Info.Sender, editMsg.GetDataMessage().GetTimestamp()) + return &bridgev2.ConvertedEdit{ + ModifiedParts: []*bridgev2.ConvertedEditPart{editPart}, + }, nil } type Bv2Receipt struct { @@ -438,7 +392,7 @@ func (s *SignalClient) handleSignalReceipt(evt *events.Receipt) { Logger() ctx := log.WithContext(context.TODO()) receipts := convertReceipts(ctx, evt.Content.Timestamp, func(ctx context.Context, msgTS uint64) (*database.Message, error) { - return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, makeMessageID(s.Client.Store.ACI, msgTS)) + return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(s.Client.Store.ACI, msgTS)) }) s.dispatchReceipts(evt.Sender, evt.Content.GetType(), receipts) } @@ -453,7 +407,7 @@ func (s *SignalClient) handleSignalReadSelf(evt *events.ReadSelf) { if err != nil { return nil, err } - return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, makeMessageID(aciUUID, msgInfo.GetTimestamp())) + return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(aciUUID, msgInfo.GetTimestamp())) }) s.dispatchReceipts(s.Client.Store.ACI, signalpb.ReceiptMessage_READ, receipts) } @@ -495,7 +449,7 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) { continue } fullContact.ContactAvatar = contact.ContactAvatar - ghost, err := s.Main.Bridge.GetGhostByID(ctx, makeUserID(contact.ACI)) + ghost, err := s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(contact.ACI)) if err != nil { log.Err(err).Msg("Failed to get ghost to update contact info") continue @@ -510,7 +464,7 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) { func (s *SignalClient) updateRemoteProfile(ctx context.Context, resendState bool) { var err error if s.Ghost == nil { - s.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, makeUserID(s.Client.Store.ACI)) + s.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(s.Client.Store.ACI)) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for remote profile update") return diff --git a/pkg/connector/id.go b/pkg/connector/id.go index 6aa4f82..49cebe6 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -17,71 +17,14 @@ package connector import ( - "fmt" - "strconv" - "strings" - "github.com/google/uuid" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" + "go.mau.fi/mautrix-signal/pkg/signalid" ) -func parseUserID(userID networkid.UserID) (uuid.UUID, error) { - serviceID, err := parseUserIDAsServiceID(userID) - if err != nil { - return uuid.Nil, err - } else if serviceID.Type != libsignalgo.ServiceIDTypeACI { - return uuid.Nil, fmt.Errorf("invalid user ID: expected ACI type") - } else { - return serviceID.UUID, nil - } -} - -func parseUserIDAsServiceID(userID networkid.UserID) (libsignalgo.ServiceID, error) { - return libsignalgo.ServiceIDFromString(string(userID)) -} - -func parsePortalID(portalID networkid.PortalID) (userID libsignalgo.ServiceID, groupID types.GroupIdentifier, err error) { - if len(portalID) == 44 { - groupID = types.GroupIdentifier(portalID) - } else { - userID, err = libsignalgo.ServiceIDFromString(string(portalID)) - } - return -} - -func parseMessageID(messageID networkid.MessageID) (sender uuid.UUID, timestamp uint64, err error) { - parts := strings.Split(string(messageID), "|") - if len(parts) != 2 { - err = fmt.Errorf("invalid message ID: expected two pipe-separated parts") - return - } - sender, err = uuid.Parse(parts[0]) - if err != nil { - return - } - timestamp, err = strconv.ParseUint(parts[1], 10, 64) - return -} - -func makeGroupPortalID(groupID types.GroupIdentifier) networkid.PortalID { - return networkid.PortalID(groupID) -} - -func makeGroupPortalKey(groupID types.GroupIdentifier) networkid.PortalKey { - return networkid.PortalKey{ - ID: makeGroupPortalID(groupID), - Receiver: "", - } -} - -func makeDMPortalID(serviceID libsignalgo.ServiceID) networkid.PortalID { - return networkid.PortalID(serviceID.String()) -} - func (s *SignalClient) makePortalKey(chatID string) networkid.PortalKey { key := networkid.PortalKey{ID: networkid.PortalID(chatID)} // For non-group chats, add receiver @@ -93,38 +36,15 @@ func (s *SignalClient) makePortalKey(chatID string) networkid.PortalKey { func (s *SignalClient) makeDMPortalKey(serviceID libsignalgo.ServiceID) networkid.PortalKey { return networkid.PortalKey{ - ID: makeDMPortalID(serviceID), + ID: signalid.MakeDMPortalID(serviceID), Receiver: s.UserLogin.ID, } } -func makeMessageID(sender uuid.UUID, timestamp uint64) networkid.MessageID { - return networkid.MessageID(fmt.Sprintf("%s|%d", sender, timestamp)) -} - -func makeUserID(user uuid.UUID) networkid.UserID { - return networkid.UserID(user.String()) -} - -func makeUserIDFromServiceID(user libsignalgo.ServiceID) networkid.UserID { - return networkid.UserID(user.String()) -} - -func makeUserLoginID(user uuid.UUID) networkid.UserLoginID { - return networkid.UserLoginID(user.String()) -} - func (s *SignalClient) makeEventSender(sender uuid.UUID) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: sender == s.Client.Store.ACI, - SenderLogin: makeUserLoginID(sender), - Sender: makeUserID(sender), + SenderLogin: signalid.MakeUserLoginID(sender), + Sender: signalid.MakeUserID(sender), } } - -func makeMessagePartID(index int) networkid.PartID { - if index == 0 { - return "" - } - return networkid.PartID(strconv.Itoa(index)) -} diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 3acd926..2e79933 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -25,6 +25,7 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow" "go.mau.fi/mautrix-signal/pkg/signalmeow/store" ) @@ -144,7 +145,7 @@ func (qr *QRLogin) qrWait(ctx context.Context) (*bridgev2.LoginStep, error) { func (qr *QRLogin) processingWait(ctx context.Context) (*bridgev2.LoginStep, error) { defer qr.cancelChan() - newLoginID := makeUserLoginID(qr.ProvData.ACI) + newLoginID := signalid.MakeUserLoginID(qr.ProvData.ACI) select { case resp := <-qr.ProvChan: diff --git a/pkg/connector/msgconvproxy.go b/pkg/connector/msgconvproxy.go deleted file mode 100644 index 8dc2db8..0000000 --- a/pkg/connector/msgconvproxy.go +++ /dev/null @@ -1,117 +0,0 @@ -// mautrix-signal - A Matrix-Signal 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 connector - -import ( - "context" - "strings" - - "google.golang.org/protobuf/proto" - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - legacydb "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/msgconv" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" -) - -type contextKey int - -var msgconvContextKey contextKey - -type msgconvContext struct { - Connector *SignalConnector - Intent bridgev2.MatrixAPI - Client *SignalClient - Portal *bridgev2.Portal - ReplyTo *database.Message -} - -type msgconvPortalMethods struct{} - -var _ msgconv.PortalMethods = (*msgconvPortalMethods)(nil) - -func (mpm *msgconvPortalMethods) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) { - mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext) - uri, _, err := mcCtx.Intent.UploadMedia(ctx, "", data, fileName, contentType) - return uri, err -} - -func (mpm *msgconvPortalMethods) DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) { - return ctx.Value(msgconvContextKey).(*msgconvContext).Connector.Bridge.Bot.DownloadMedia(ctx, uri, nil) -} - -func (mpm *msgconvPortalMethods) GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID) { - // Matrix replies are handled in bridgev2 code - return "", "" -} - -func (mpm *msgconvPortalMethods) GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote { - mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext) - if mcCtx.ReplyTo == nil { - return nil - } - quote := &signalpb.DataMessage_Quote{ - Id: proto.Uint64(uint64(mcCtx.ReplyTo.Timestamp.UnixMilli())), - AuthorAci: proto.String(string(mcCtx.ReplyTo.SenderID)), - Type: signalpb.DataMessage_Quote_NORMAL.Enum(), - } - if mcCtx.ReplyTo.Metadata.(*MessageMetadata).ContainsAttachments { - quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1) - } - return quote -} - -func (mpm *msgconvPortalMethods) GetClient(ctx context.Context) *signalmeow.Client { - return ctx.Value(msgconvContextKey).(*msgconvContext).Client.Client -} - -func (mpm *msgconvPortalMethods) GetData(ctx context.Context) *legacydb.Portal { - mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext) - portal := mcCtx.Portal - userID, groupID, _ := parsePortalID(portal.ID) - chatID := string(groupID) - if chatID == "" { - chatID = userID.String() - } - pk := legacydb.PortalKey{ - ChatID: chatID, - } - if len(chatID) != 44 { - pk.Receiver = mcCtx.Client.Client.Store.ACI - } - return &legacydb.Portal{ - PortalKey: pk, - MXID: portal.MXID, - Name: portal.Name, - Topic: portal.Topic, - //AvatarPath: "", - //AvatarHash: "", - //AvatarURL: id.ContentURI{}, - NameSet: portal.NameSet, - AvatarSet: portal.AvatarSet, - TopicSet: portal.TopicSet, - Revision: portal.Metadata.(*PortalMetadata).Revision, - // Hack to prevent encryption while using the bridge as a "local bridge" - Encrypted: !strings.HasSuffix(portal.Bridge.Matrix.ServerName(), ".localhost"), - //RelayUserID: portal.Relay.UserMXID, - ExpirationTime: uint32(portal.Disappear.Timer.Seconds()), - } -} diff --git a/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go similarity index 74% rename from msgconv/from-matrix.go rename to pkg/msgconv/from-matrix.go index 9a14ead..3b61b9f 100644 --- a/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -18,35 +18,37 @@ package msgconv import ( "context" - "errors" "fmt" "strings" "time" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "go.mau.fi/util/exerrors" "go.mau.fi/util/exmime" "go.mau.fi/util/ffmpeg" "go.mau.fi/util/variationselector" "golang.org/x/exp/constraints" "google.golang.org/protobuf/proto" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt" + "go.mau.fi/mautrix-signal/pkg/signalid" + "go.mau.fi/mautrix-signal/pkg/signalmeow" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) -var ( - ErrUnsupportedMsgType = errors.New("unsupported msgtype") - ErrMediaDownloadFailed = errors.New("failed to download media") - ErrMediaDecryptFailed = errors.New("failed to decrypt media") - ErrMediaConvertFailed = errors.New("failed to convert") - ErrMediaUploadFailed = errors.New("failed to upload media") - ErrInvalidGeoURI = errors.New("invalid `geo:` URI in message") -) - -func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent, relaybotFormatted bool) (*signalpb.DataMessage, error) { +func (mc *MessageConverter) ToSignal( + ctx context.Context, + client *signalmeow.Client, + portal *bridgev2.Portal, + evt *event.Event, + content *event.MessageEventContent, + relaybotFormatted bool, + replyTo *database.Message, +) (*signalpb.DataMessage, error) { + ctx = context.WithValue(ctx, contextKeyClient, client) + ctx = context.WithValue(ctx, contextKeyPortal, portal) if evt.Type == event.EventSticker { content.MsgType = event.MessageType(event.EventSticker.Type) } @@ -59,11 +61,23 @@ func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, cont } dm := &signalpb.DataMessage{ Timestamp: &ts, - Quote: mc.GetSignalReply(ctx, content), - Preview: mc.convertURLPreviewToSignal(ctx, evt), + Preview: mc.convertURLPreviewToSignal(ctx, content), } - if expirationTime := mc.GetData(ctx).ExpirationTime; expirationTime != 0 { - dm.ExpireTimer = proto.Uint32(uint32(expirationTime)) + if replyTo != nil { + authorACI, messageID, err := signalid.ParseMessageID(replyTo.ID) + if err == nil { + dm.Quote = &signalpb.DataMessage_Quote{ + Id: proto.Uint64(messageID), + AuthorAci: proto.String(authorACI.String()), + Type: signalpb.DataMessage_Quote_NORMAL.Enum(), + } + if replyTo.Metadata.(*signalid.MessageMetadata).ContainsAttachments { + dm.Quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1) + } + } + } + if portal.Disappear.Timer > 0 { + dm.ExpireTimer = proto.Uint32(uint32(portal.Disappear.Timer.Seconds())) } if content.MsgType == event.MsgEmote && !relaybotFormatted { content.Body = "/me " + content.Body @@ -113,13 +127,13 @@ func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, cont case event.MsgLocation: lat, lon, err := parseGeoURI(content.GeoURI) if err != nil { - log.Err(err).Msg("Invalid geo URI") + zerolog.Ctx(ctx).Err(err).Msg("Invalid geo URI") return nil, err } locationString := fmt.Sprintf(mc.LocationFormat, lat, lon) dm.Body = &locationString default: - return nil, fmt.Errorf("%w %s", ErrUnsupportedMsgType, content.MsgType) + return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType) } return dm, nil } @@ -133,44 +147,33 @@ func maybeInt[T constraints.Integer](v T) *T { func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*signalpb.AttachmentPointer, error) { log := zerolog.Ctx(ctx) - mxc := content.URL - if content.File != nil { - mxc = content.File.URL - } - data, err := mc.DownloadMatrixMedia(ctx, mxc) + data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) if err != nil { - return nil, exerrors.NewDualError(ErrMediaDownloadFailed, err) - } - if content.File != nil { - err = content.File.DecryptInPlace(data) - if err != nil { - return nil, exerrors.NewDualError(ErrMediaDecryptFailed, err) - } + return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) } fileName := content.Body if content.FileName != "" { fileName = content.FileName } - _, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"] mime := content.GetInfo().MimeType - if isVoice { + if content.MSC3245Voice != nil && ffmpeg.Supported() { data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mime) if err != nil { return nil, err } mime = "audio/aac" fileName += ".m4a" - } else if evt.Type == event.EventSticker && mime != "image/webp" && mime != "image/png" && mime != "image/apng" { + } else if evt.Type == event.EventSticker { switch mime { case "image/webp", "image/png", "image/apng": // allowed case "image/gif": - if !mc.ConvertGIFToAPNG { + if !ffmpeg.Supported() { return nil, fmt.Errorf("converting gif stickers is not supported") } data, err = ffmpeg.ConvertBytes(ctx, data, ".apng", []string{}, []string{}, mime) if err != nil { - return nil, fmt.Errorf("%w gif to apng: %w", ErrMediaConvertFailed, err) + return nil, fmt.Errorf("%w (gif to apng): %w", bridgev2.ErrMediaConvertFailed, err) } fileName += ".apng" mime = "image/apng" @@ -178,12 +181,12 @@ func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event. return nil, fmt.Errorf("unsupported content type for sticker %s", mime) } } - att, err := mc.GetClient(ctx).UploadAttachment(ctx, data) + att, err := getClient(ctx).UploadAttachment(ctx, data) if err != nil { log.Err(err).Msg("Failed to upload file") - return nil, exerrors.NewDualError(ErrMediaUploadFailed, err) + return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err) } - if isVoice { + if content.MSC3245Voice != nil && mime == "audio/aac" { att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_VOICE_MESSAGE)) } att.ContentType = proto.String(mime) diff --git a/msgconv/from-signal.go b/pkg/msgconv/from-signal.go similarity index 72% rename from msgconv/from-signal.go rename to pkg/msgconv/from-signal.go index 918c749..e4ab6dc 100644 --- a/msgconv/from-signal.go +++ b/pkg/msgconv/from-signal.go @@ -26,49 +26,22 @@ import ( "time" "github.com/emersion/go-vcard" + "github.com/google/uuid" "github.com/rs/zerolog" "go.mau.fi/util/exfmt" "go.mau.fi/util/exmime" "go.mau.fi/util/ffmpeg" - "golang.org/x/exp/slices" - "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalmeow" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) -type ConvertedMessage struct { - Parts []*ConvertedMessagePart - Timestamp uint64 - DisappearIn uint32 -} - -func (cm *ConvertedMessage) MergeCaption() { - if len(cm.Parts) != 2 || cm.Parts[1].Content.MsgType != event.MsgText { - return - } - switch cm.Parts[0].Content.MsgType { - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: - default: - return - } - mediaContent := cm.Parts[0].Content - textContent := cm.Parts[1].Content - mediaContent.FileName = mediaContent.Body - mediaContent.Body = textContent.Body - mediaContent.Format = textContent.Format - mediaContent.FormattedBody = textContent.FormattedBody - cm.Parts = cm.Parts[:1] -} - -type ConvertedMessagePart struct { - Type event.Type - Content *event.MessageEventContent - Extra map[string]any -} - func calculateLength(dm *signalpb.DataMessage) int { if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 { return 1 @@ -96,19 +69,30 @@ func CanConvertSignal(dm *signalpb.DataMessage) bool { return calculateLength(dm) > 0 } -func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessage { - cm := &ConvertedMessage{ - Timestamp: dm.GetTimestamp(), - DisappearIn: dm.GetExpireTimer(), - Parts: make([]*ConvertedMessagePart, 0, calculateLength(dm)), +func (mc *MessageConverter) ToMatrix( + ctx context.Context, + client *signalmeow.Client, + portal *bridgev2.Portal, + intent bridgev2.MatrixAPI, + dm *signalpb.DataMessage, +) *bridgev2.ConvertedMessage { + ctx = context.WithValue(ctx, contextKeyClient, client) + ctx = context.WithValue(ctx, contextKeyPortal, portal) + ctx = context.WithValue(ctx, contextKeyIntent, intent) + cm := &bridgev2.ConvertedMessage{ + ReplyTo: nil, + ThreadRoot: nil, + Parts: make([]*bridgev2.ConvertedMessagePart, 0, calculateLength(dm)), } if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 { cm.Parts = append(cm.Parts, mc.ConvertDisappearingTimerChangeToMatrix(ctx, dm.GetExpireTimer(), true)) - // Don't disappear disappearing timer changes - cm.DisappearIn = 0 // Don't allow any other parts in a disappearing timer change message return cm } + if dm.GetExpireTimer() > 0 { + cm.Disappear.Type = database.DisappearingTypeAfterRead + cm.Disappear.Timer = time.Duration(dm.GetExpireTimer()) * time.Second + } if dm.Sticker != nil { cm.Parts = append(cm.Parts, mc.convertStickerToMatrix(ctx, dm.Sticker)) // Don't allow any other parts in a sticker message @@ -139,7 +123,7 @@ func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessa cm.Parts = append(cm.Parts, mc.convertTextToMatrix(ctx, dm)) } if len(cm.Parts) == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) { - cm.Parts = append(cm.Parts, &ConvertedMessagePart{ + cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -147,23 +131,28 @@ func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessa }, }) } - replyTo, sender := mc.GetMatrixReply(ctx, dm.Quote) - for _, part := range cm.Parts { - if part.Content.Mentions == nil { - part.Content.Mentions = &event.Mentions{} + cm.MergeCaption() + for i, part := range cm.Parts { + part.ID = signalid.MakeMessagePartID(i) + part.DBMetadata = &signalid.MessageMetadata{ + ContainsAttachments: len(dm.GetAttachments()) > 0, } - if replyTo != "" { - part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo) - if !slices.Contains(part.Content.Mentions.UserIDs, sender) { - part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender) + } + if dm.Quote != nil { + authorACI, err := uuid.Parse(dm.Quote.GetAuthorAci()) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("author_aci", dm.Quote.GetAuthorAci()).Msg("Failed to parse quote author ACI") + } else { + cm.ReplyTo = &networkid.MessageOptionalPartID{ + MessageID: signalid.MakeMessageID(authorACI, dm.Quote.GetId()), } } } return cm } -func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.Context, timer uint32, updatePortal bool) *ConvertedMessagePart { - part := &ConvertedMessagePart{ +func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.Context, timer uint32, updatePortal bool) *bridgev2.ConvertedMessagePart { + part := &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -174,35 +163,36 @@ func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.C part.Content.Body = "Disappearing messages disabled" } if updatePortal { - if mc.UpdateDisappearing != nil { - mc.UpdateDisappearing(ctx, time.Duration(timer)*time.Second) + portal := getPortal(ctx) + portal.Disappear.Timer = time.Duration(timer) * time.Second + if timer == 0 { + portal.Disappear.Type = "" } else { - portal := mc.GetData(ctx) - portal.ExpirationTime = timer - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database") - } + portal.Disappear.Type = database.DisappearingTypeAfterRead + } + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database") } } return part } -func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessagePart { +func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *bridgev2.ConvertedMessagePart { content := signalfmt.Parse(ctx, dm.GetBody(), dm.GetBodyRanges(), mc.SignalFmtParams) extra := map[string]any{} if len(dm.Preview) > 0 { - extra["com.beeper.linkpreviews"] = mc.convertURLPreviewsToBeeper(ctx, dm.Preview) + content.BeeperLinkPreviews = mc.convertURLPreviewsToBeeper(ctx, dm.Preview) } - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: extra, } } -func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *signalpb.DataMessage_Payment) *ConvertedMessagePart { - return &ConvertedMessagePart{ +func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *signalpb.DataMessage_Payment) *bridgev2.ConvertedMessagePart { + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -214,8 +204,8 @@ func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *s } } -func (mc *MessageConverter) convertGiftBadgeToMatrix(_ context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *ConvertedMessagePart { - return &ConvertedMessagePart{ +func (mc *MessageConverter) convertGiftBadgeToMatrix(_ context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *bridgev2.ConvertedMessagePart { + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -303,7 +293,7 @@ func (mc *MessageConverter) convertContactToVCard(ctx context.Context, contact * return card } -func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact) *ConvertedMessagePart { +func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact) *bridgev2.ConvertedMessagePart { card := mc.convertContactToVCard(ctx, contact) contact.Avatar = nil extraData := map[string]any{ @@ -313,7 +303,7 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact err := vcard.NewEncoder(&buf).Encode(card) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to encode vCard") - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -323,30 +313,6 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact } } data := buf.Bytes() - var file *event.EncryptedFileInfo - uploadMime := "text/vcard" - uploadFileName := "contact.vcf" - if mc.GetData(ctx).Encrypted { - file = &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - uploadMime = "application/octet-stream" - uploadFileName = "" - } - mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to upload vCard") - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Failed to upload vCard", - }, - Extra: extraData, - } - } displayName := contact.GetName().GetDisplayName() if displayName == "" { displayName = contact.GetName().GetGivenName() @@ -368,24 +334,30 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact Size: len(data), }, } - if file != nil { - file.URL = mxc - content.File = file - } else { - content.URL = mxc + content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, content.Info.MimeType, content.Body) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to upload vCard") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: "Failed to upload vCard", + }, + Extra: extraData, + } } - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: extraData, } } -func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer) *ConvertedMessagePart { +func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer) *bridgev2.ConvertedMessagePart { part, err := mc.reuploadAttachment(ctx, att) if err != nil { zerolog.Ctx(ctx).Err(err).Int("attachment_index", index).Msg("Failed to handle attachment") - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -396,11 +368,11 @@ func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index return part } -func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker) *ConvertedMessagePart { +func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker) *bridgev2.ConvertedMessagePart { converted, err := mc.reuploadAttachment(ctx, sticker.GetData()) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to handle sticker") - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -437,7 +409,7 @@ func (mc *MessageConverter) downloadSignalLongText(ctx context.Context, att *sig return &longBody, nil } -func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*ConvertedMessagePart, error) { +func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*bridgev2.ConvertedMessagePart, error) { data, err := signalmeow.DownloadAttachment(ctx, att) if err != nil { return nil, fmt.Errorf("failed to download attachment: %w", err) @@ -447,43 +419,28 @@ func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalp mimeType = http.DetectContentType(data) } fileName := att.GetFileName() - extra := map[string]any{} - if mc.ConvertVoiceMessages && att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 { + content := &event.MessageEventContent{ + Info: &event.FileInfo{ + Width: int(att.GetWidth()), + Height: int(att.GetHeight()), + Size: len(data), + }, + } + if att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 && ffmpeg.Supported() { data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType) if err != nil { return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err) } fileName += ".ogg" mimeType = "audio/ogg" - extra["org.matrix.msc3245.voice"] = map[string]any{} + content.MSC3245Voice = &event.MSC3245Voice{} // TODO include duration here (and in info) if there's some easy way to extract it with ffmpeg - //extra["org.matrix.msc1767.audio"] = map[string]any{"duration": ???} + //content.MSC1767Audio = &event.MSC1767Audio{} } - var file *event.EncryptedFileInfo - uploadMime := mimeType - uploadFileName := fileName - if mc.GetData(ctx).Encrypted { - file = &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - uploadMime = "application/octet-stream" - uploadFileName = "" - } - mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime) + content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, fileName, mimeType) if err != nil { return nil, err } - content := &event.MessageEventContent{ - Body: fileName, - Info: &event.FileInfo{ - MimeType: mimeType, - Width: int(att.GetWidth()), - Height: int(att.GetHeight()), - Size: len(data), - }, - } if att.GetBlurHash() != "" { content.Info.Blurhash = att.GetBlurHash() content.Info.AnoaBlurhash = att.GetBlurHash() @@ -498,18 +455,13 @@ func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalp default: content.MsgType = event.MsgFile } + content.Body = fileName + content.Info.MimeType = mimeType if content.Body == "" { content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(mimeType) } - if file != nil { - file.URL = mxc - content.File = file - } else { - content.URL = mxc - } - return &ConvertedMessagePart{ + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, - Extra: extra, }, nil } diff --git a/msgconv/matrixfmt/convert.go b/pkg/msgconv/matrixfmt/convert.go similarity index 100% rename from msgconv/matrixfmt/convert.go rename to pkg/msgconv/matrixfmt/convert.go diff --git a/msgconv/matrixfmt/convert_test.go b/pkg/msgconv/matrixfmt/convert_test.go similarity index 97% rename from msgconv/matrixfmt/convert_test.go rename to pkg/msgconv/matrixfmt/convert_test.go index 6f9d66e..240ca98 100644 --- a/msgconv/matrixfmt/convert_test.go +++ b/pkg/msgconv/matrixfmt/convert_test.go @@ -10,8 +10,8 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" ) var formatParams = &matrixfmt.HTMLParser{ diff --git a/msgconv/matrixfmt/html.go b/pkg/msgconv/matrixfmt/html.go similarity index 99% rename from msgconv/matrixfmt/html.go rename to pkg/msgconv/matrixfmt/html.go index 94571da..1e3d249 100644 --- a/msgconv/matrixfmt/html.go +++ b/pkg/msgconv/matrixfmt/html.go @@ -13,7 +13,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" ) type EntityString struct { diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go new file mode 100644 index 0000000..1d1fee8 --- /dev/null +++ b/pkg/msgconv/msgconv.go @@ -0,0 +1,107 @@ +// mautrix-signal - A Matrix-signal 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 msgconv + +import ( + "context" + + "github.com/google/uuid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/signalid" + "go.mau.fi/mautrix-signal/pkg/signalmeow" +) + +type contextKey int + +const ( + contextKeyPortal contextKey = iota + contextKeyClient + contextKeyIntent +) + +type MessageConverter struct { + Bridge *bridgev2.Bridge + + SignalFmtParams *signalfmt.FormatParams + MatrixFmtParams *matrixfmt.HTMLParser + + MaxFileSize int64 + LocationFormat string +} + +func NewMessageConverter(br *bridgev2.Bridge, locationFormat string) *MessageConverter { + return &MessageConverter{ + Bridge: br, + SignalFmtParams: &signalfmt.FormatParams{ + GetUserInfo: func(ctx context.Context, uuid uuid.UUID) signalfmt.UserInfo { + ghost, err := br.GetGhostByID(ctx, signalid.MakeUserID(uuid)) + if err != nil { + // TODO log? + return signalfmt.UserInfo{} + } + userInfo := signalfmt.UserInfo{ + MXID: ghost.Intent.GetMXID(), + Name: ghost.Name, + } + userLogin := br.GetCachedUserLoginByID(networkid.UserLoginID(uuid.String())) + if userLogin != nil { + userInfo.MXID = userLogin.UserMXID + // TODO find matrix user displayname? + } + return userInfo + }, + }, + MatrixFmtParams: &matrixfmt.HTMLParser{ + GetUUIDFromMXID: func(ctx context.Context, userID id.UserID) uuid.UUID { + parsed, ok := br.Matrix.ParseGhostMXID(userID) + if ok { + u, _ := uuid.Parse(string(parsed)) + return u + } + user, _ := br.GetExistingUserByMXID(ctx, userID) + // TODO log errors? + if user != nil { + preferredLogin, _, _ := getPortal(ctx).FindPreferredLogin(ctx, user, true) + if preferredLogin != nil { + u, _ := uuid.Parse(string(preferredLogin.ID)) + return u + } + } + return uuid.Nil + }, + }, + MaxFileSize: 50 * 1024 * 1024, + LocationFormat: locationFormat, + } +} + +func getClient(ctx context.Context) *signalmeow.Client { + return ctx.Value(contextKeyClient).(*signalmeow.Client) +} + +func getPortal(ctx context.Context) *bridgev2.Portal { + return ctx.Value(contextKeyPortal).(*bridgev2.Portal) +} + +func getIntent(ctx context.Context) bridgev2.MatrixAPI { + return ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI) +} diff --git a/msgconv/signalfmt/convert.go b/pkg/msgconv/signalfmt/convert.go similarity index 100% rename from msgconv/signalfmt/convert.go rename to pkg/msgconv/signalfmt/convert.go diff --git a/msgconv/signalfmt/convert_test.go b/pkg/msgconv/signalfmt/convert_test.go similarity index 99% rename from msgconv/signalfmt/convert_test.go rename to pkg/msgconv/signalfmt/convert_test.go index 1b2d693..eb65542 100644 --- a/msgconv/signalfmt/convert_test.go +++ b/pkg/msgconv/signalfmt/convert_test.go @@ -26,7 +26,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" + "go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) diff --git a/msgconv/signalfmt/html.go b/pkg/msgconv/signalfmt/html.go similarity index 100% rename from msgconv/signalfmt/html.go rename to pkg/msgconv/signalfmt/html.go diff --git a/msgconv/signalfmt/tags.go b/pkg/msgconv/signalfmt/tags.go similarity index 100% rename from msgconv/signalfmt/tags.go rename to pkg/msgconv/signalfmt/tags.go diff --git a/msgconv/signalfmt/tree.go b/pkg/msgconv/signalfmt/tree.go similarity index 100% rename from msgconv/signalfmt/tree.go rename to pkg/msgconv/signalfmt/tree.go diff --git a/msgconv/urlpreview.go b/pkg/msgconv/urlpreview.go similarity index 53% rename from msgconv/urlpreview.go rename to pkg/msgconv/urlpreview.go index 168dcb9..d2d3b23 100644 --- a/msgconv/urlpreview.go +++ b/pkg/msgconv/urlpreview.go @@ -18,37 +18,27 @@ package msgconv import ( "context" - "encoding/json" - "regexp" "time" "github.com/rs/zerolog" - "github.com/tidwall/gjson" "google.golang.org/protobuf/proto" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" ) -type BeeperLinkPreview struct { - mautrix.RespPreviewURL - MatchedURL string `json:"matched_url"` - ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` -} - -func (mc *MessageConverter) convertURLPreviewsToBeeper(ctx context.Context, preview []*signalpb.Preview) []*BeeperLinkPreview { - output := make([]*BeeperLinkPreview, len(preview)) +func (mc *MessageConverter) convertURLPreviewsToBeeper(ctx context.Context, preview []*signalpb.Preview) []*event.BeeperLinkPreview { + output := make([]*event.BeeperLinkPreview, len(preview)) for i, p := range preview { output[i] = mc.convertURLPreviewToBeeper(ctx, p) } return output } -func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, preview *signalpb.Preview) *BeeperLinkPreview { - output := &BeeperLinkPreview{ +func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, preview *signalpb.Preview) *event.BeeperLinkPreview { + output := &event.BeeperLinkPreview{ MatchedURL: preview.GetUrl(), - RespPreviewURL: mautrix.RespPreviewURL{ + LinkPreview: event.LinkPreview{ CanonicalURL: preview.GetUrl(), Title: preview.GetTitle(), Description: preview.GetDescription(), @@ -70,65 +60,34 @@ func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, previ return output } -var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`) - -func (mc *MessageConverter) convertURLPreviewToSignal(ctx context.Context, evt *event.Event) []*signalpb.Preview { - var previews []*BeeperLinkPreview - - log := zerolog.Ctx(ctx) - rawPreview := gjson.GetBytes(evt.Content.VeryRaw, `com\.beeper\.linkpreviews`) - if rawPreview.Exists() && rawPreview.IsArray() { - if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 { - return nil - } - } /* else if portal.bridge.Config.Bridge.URLPreviews { - if matchedURL := URLRegex.FindString(evt.Content.AsMessage().Body); len(matchedURL) == 0 { - return nil - } else if parsed, err := url.Parse(matchedURL); err != nil { - return nil - } else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil { - return nil - } else if mxPreview, err := portal.MainIntent().GetURLPreview(parsed.String()); err != nil { - log.Err(err).Str("matched_url", matchedURL).Msg("Failed to fetch preview for URL found in message") - return nil - } else { - previews = []*BeeperLinkPreview{{ - RespPreviewURL: *mxPreview, - MatchedURL: matchedURL, - }} - } - }*/ - if len(previews) == 0 { +func (mc *MessageConverter) convertURLPreviewToSignal(ctx context.Context, content *event.MessageEventContent) []*signalpb.Preview { + if len(content.BeeperLinkPreviews) == 0 { return nil } - output := make([]*signalpb.Preview, len(previews)) - for i, preview := range previews { + output := make([]*signalpb.Preview, len(content.BeeperLinkPreviews)) + for i, preview := range content.BeeperLinkPreviews { output[i] = &signalpb.Preview{ Url: proto.String(preview.MatchedURL), Title: proto.String(preview.Title), Description: proto.String(preview.Description), Date: proto.Uint64(uint64(time.Now().UnixMilli())), } - imageMXC := preview.ImageURL - if preview.ImageEncryption != nil { - imageMXC = preview.ImageEncryption.URL - } - if imageMXC != "" { - data, err := mc.DownloadMatrixMedia(ctx, imageMXC) + if preview.ImageURL != "" || preview.ImageEncryption != nil { + data, err := mc.Bridge.Bot.DownloadMedia(ctx, preview.ImageURL, preview.ImageEncryption) if err != nil { - log.Err(err).Int("preview_index", i).Msg("Failed to download URL preview image") + zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to download URL preview image") continue } if preview.ImageEncryption != nil { err = preview.ImageEncryption.DecryptInPlace(data) if err != nil { - log.Err(err).Int("preview_index", i).Msg("Failed to decrypt URL preview image") + zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to decrypt URL preview image") continue } } - uploaded, err := mc.GetClient(ctx).UploadAttachment(ctx, data) + uploaded, err := getClient(ctx).UploadAttachment(ctx, data) if err != nil { - log.Err(err).Int("preview_index", i).Msg("Failed to reupload URL preview image") + zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to reupload URL preview image") continue } uploaded.ContentType = proto.String(preview.ImageType) diff --git a/database/upgrades/upgrades.go b/pkg/signalid/dbmeta.go similarity index 54% rename from database/upgrades/upgrades.go rename to pkg/signalid/dbmeta.go index 20f60f4..2d85a0c 100644 --- a/database/upgrades/upgrades.go +++ b/pkg/signalid/dbmeta.go @@ -1,5 +1,5 @@ // mautrix-signal - A Matrix-Signal puppeting bridge. -// Copyright (C) 2023 Tulir Asokan +// 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 @@ -14,27 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package upgrades +package signalid -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, 12, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error { - return errors.New("please upgrade to mautrix-signal v0.4.3 before upgrading to a newer version") - }) - Table.Register(1, 13, 0, "Jump to version 13", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error { - return nil - }) - Table.RegisterFS(rawUpgrades) +type PortalMetadata struct { + Revision uint32 `json:"revision"` +} + +type MessageMetadata struct { + ContainsAttachments bool `json:"contains_attachments,omitempty"` } diff --git a/pkg/signalid/ids.go b/pkg/signalid/ids.go new file mode 100644 index 0000000..f99bf9d --- /dev/null +++ b/pkg/signalid/ids.go @@ -0,0 +1,105 @@ +// mautrix-signal - A Matrix-Signal 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 signalid + +import ( + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-signal/pkg/libsignalgo" + "go.mau.fi/mautrix-signal/pkg/signalmeow/types" +) + +func ParseUserID(userID networkid.UserID) (uuid.UUID, error) { + serviceID, err := ParseUserIDAsServiceID(userID) + if err != nil { + return uuid.Nil, err + } else if serviceID.Type != libsignalgo.ServiceIDTypeACI { + return uuid.Nil, fmt.Errorf("invalid user ID: expected ACI type") + } else { + return serviceID.UUID, nil + } +} + +func ParseUserIDAsServiceID(userID networkid.UserID) (libsignalgo.ServiceID, error) { + return libsignalgo.ServiceIDFromString(string(userID)) +} + +func ParsePortalID(portalID networkid.PortalID) (userID libsignalgo.ServiceID, groupID types.GroupIdentifier, err error) { + if len(portalID) == 44 { + groupID = types.GroupIdentifier(portalID) + } else { + userID, err = libsignalgo.ServiceIDFromString(string(portalID)) + } + return +} + +func ParseMessageID(messageID networkid.MessageID) (sender uuid.UUID, timestamp uint64, err error) { + parts := strings.Split(string(messageID), "|") + if len(parts) != 2 { + err = fmt.Errorf("invalid message ID: expected two pipe-separated parts") + return + } + sender, err = uuid.Parse(parts[0]) + if err != nil { + return + } + timestamp, err = strconv.ParseUint(parts[1], 10, 64) + return +} + +func MakeGroupPortalID(groupID types.GroupIdentifier) networkid.PortalID { + return networkid.PortalID(groupID) +} + +func MakeGroupPortalKey(groupID types.GroupIdentifier) networkid.PortalKey { + return networkid.PortalKey{ + ID: MakeGroupPortalID(groupID), + Receiver: "", + } +} + +func MakeDMPortalID(serviceID libsignalgo.ServiceID) networkid.PortalID { + return networkid.PortalID(serviceID.String()) +} + +func MakeMessageID(sender uuid.UUID, timestamp uint64) networkid.MessageID { + return networkid.MessageID(fmt.Sprintf("%s|%d", sender, timestamp)) +} + +func MakeUserID(user uuid.UUID) networkid.UserID { + return networkid.UserID(user.String()) +} + +func MakeUserIDFromServiceID(user libsignalgo.ServiceID) networkid.UserID { + return networkid.UserID(user.String()) +} + +func MakeUserLoginID(user uuid.UUID) networkid.UserLoginID { + return networkid.UserLoginID(user.String()) +} + +func MakeMessagePartID(index int) networkid.PartID { + if index == 0 { + return "" + } + return networkid.PartID(strconv.Itoa(index)) +} diff --git a/portal.go b/portal.go deleted file mode 100644 index 37fabe6..0000000 --- a/portal.go +++ /dev/null @@ -1,3026 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber, 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/hex" - "errors" - "fmt" - "net/http" - "reflect" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/rs/zerolog" - "go.mau.fi/util/exfmt" - "go.mau.fi/util/jsontime" - "go.mau.fi/util/variationselector" - "google.golang.org/protobuf/proto" - "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/id" - - "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/msgconv" - "go.mau.fi/mautrix-signal/msgconv/matrixfmt" - "go.mau.fi/mautrix-signal/msgconv/signalfmt" - "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - "go.mau.fi/mautrix-signal/pkg/signalmeow/events" - signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -func (br *SignalBridge) GetPortalByMXID(mxid id.RoomID) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - portal, ok := br.portalsByMXID[mxid] - if !ok { - dbPortal, err := br.DB.Portal.GetByMXID(context.TODO(), mxid) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get portal from database") - return nil - } - return br.loadPortal(context.TODO(), dbPortal, nil) - } - - return portal -} - -func (br *SignalBridge) GetPortalByChatID(key database.PortalKey) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - return br.unlockedGetPortalByChatID(key, true) -} - -func (br *SignalBridge) GetPortalByChatIDIfExists(key database.PortalKey) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - return br.unlockedGetPortalByChatID(key, false) -} - -func (br *SignalBridge) unlockedGetPortalByChatID(key database.PortalKey, createIfNotExists bool) *Portal { - // If this PortalKey is for a group, Receiver should be empty - if key.UserID().IsEmpty() { - key.Receiver = uuid.Nil - } - portal, ok := br.portalsByID[key] - if !ok { - dbPortal, err := br.DB.Portal.GetByChatID(context.TODO(), key) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get portal from database") - return nil - } - keyIfNotExists := &key - if !createIfNotExists { - keyIfNotExists = nil - } - return br.loadPortal(context.TODO(), dbPortal, keyIfNotExists) - } - return portal -} - -func (br *SignalBridge) GetAllPortalsWithMXID() []*Portal { - portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO())) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all portals with mxid") - return nil - } - return portals -} - -func (br *SignalBridge) FindPrivateChatPortalsWith(userID uuid.UUID) []*Portal { - portals, err := br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(context.TODO(), userID)) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all DM portals with user") - return nil - } - return portals -} - -func (br *SignalBridge) GetAllIPortals() (iportals []bridge.Portal) { - portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO())) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all portals with mxid") - return nil - } - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *SignalBridge) loadPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey) *Portal { - if dbPortal == nil { - if key == nil { - return nil - } - - dbPortal = br.DB.Portal.New() - dbPortal.PortalKey = *key - err := dbPortal.Insert(ctx) - if err != nil { - br.ZLog.Err(err).Msg("Failed to insert new portal") - return nil - } - } - - portal := br.NewPortal(dbPortal) - - br.portalsByID[portal.PortalKey] = portal - if portal.MXID != "" { - br.portalsByMXID[portal.MXID] = portal - } - - return portal -} - -func (br *SignalBridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) ([]*Portal, error) { - if err != nil { - return nil, err - } - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - - portal, ok := br.portalsByID[dbPortal.PortalKey] - if !ok { - portal = br.loadPortal(context.TODO(), dbPortal, nil) - } - - output[index] = portal - } - - return output, nil -} - -type portalSignalMessage struct { - evt *events.ChatEvent - user *User -} - -type portalMatrixMessage struct { - evt *event.Event - user *User -} - -type Portal struct { - *database.Portal - - MsgConv *msgconv.MessageConverter - - bridge *SignalBridge - log zerolog.Logger - - roomCreateLock sync.Mutex - encryptLock sync.Mutex - - signalMessages chan portalSignalMessage - matrixMessages chan portalMatrixMessage - - currentlyTyping []id.UserID - currentlyTypingLock sync.Mutex - - relayUser *User -} - -var signalFormatParams *signalfmt.FormatParams -var matrixFormatParams *matrixfmt.HTMLParser - -func (br *SignalBridge) NewPortal(dbPortal *database.Portal) *Portal { - log := br.ZLog.With().Str("chat_id", dbPortal.ChatID).Logger() - if dbPortal.MXID != "" { - log = log.With().Stringer("room_id", dbPortal.MXID).Logger() - } - - portal := &Portal{ - Portal: dbPortal, - bridge: br, - log: log, - - signalMessages: make(chan portalSignalMessage, br.Config.Bridge.PortalMessageBuffer), - matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), - } - portal.MsgConv = &msgconv.MessageConverter{ - PortalMethods: portal, - SignalFmtParams: signalFormatParams, - MatrixFmtParams: matrixFormatParams, - ConvertVoiceMessages: true, - MaxFileSize: br.MediaConfig.UploadSize, - LocationFormat: br.Config.Bridge.LocationFormat, - } - go portal.messageLoop() - - return portal -} - -func init() { - event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) - event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) -} - -var ( - _ bridge.Portal = (*Portal)(nil) - _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) - _ bridge.TypingPortal = (*Portal)(nil) - _ bridge.DisappearingPortal = (*Portal)(nil) - //_ bridge.MembershipHandlingPortal = (*Portal)(nil) - //_ bridge.MetaHandlingPortal = (*Portal)(nil) -) - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - err := portal.Update(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to update portal in database after marking as encrypted") - } -} - -func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() { - portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt} - } -} - -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) IsPrivateChat() bool { - return !portal.UserID().IsEmpty() -} - -func (portal *Portal) IsNoteToSelf() bool { - userID := portal.UserID() - return !userID.IsEmpty() && userID.UUID == portal.Receiver -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - dmPuppet := portal.GetDMPuppet() - if dmPuppet != nil { - return dmPuppet.DefaultIntent() - } - - return portal.bridge.Bot -} - -type CustomBridgeInfoContent struct { - event.BridgeEventContent - RoomType string `json:"com.beeper.room_type,omitempty"` -} - -func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: "signal", - DisplayName: "Signal", - AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - ExternalURL: "https://signal.org/", - }, - Channel: event.BridgeInfoSection{ - ID: portal.ChatID, - DisplayName: portal.Name, - AvatarURL: portal.AvatarURL.CUString(), - }, - } - bridgeInfoStateKey := fmt.Sprintf("fi.mau.signal://signal/%s", portal.ChatID) - bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://signal.me/#p/%s", portal.ChatID) - var roomType string - if portal.IsPrivateChat() { - roomType = "dm" - } - return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType} -} - -func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { - if len(portal.MXID) == 0 { - portal.log.Debug().Msg("Not updating bridge info: no Matrix room created") - return - } - portal.log.Debug().Msg("Updating bridge info...") - stateKey, content := portal.getBridgeInfo() - _, err := portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateBridge, stateKey, content) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to update m.bridge") - } - // 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 { - portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge") - } -} - -func (portal *Portal) messageLoop() { - for { - select { - case msg := <-portal.matrixMessages: - portal.handleMatrixMessages(msg) - case msg := <-portal.signalMessages: - portal.handleSignalMessage(msg) - } - } -} - -func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { - log := portal.log.With(). - Str("action", "handle matrix event"). - Stringer("event_id", msg.evt.ID). - Str("event_type", msg.evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - - switch msg.evt.Type { - case event.EventMessage, event.EventSticker: - portal.handleMatrixMessage(ctx, msg.user, msg.evt) - case event.EventRedaction: - portal.handleMatrixRedaction(ctx, msg.user, msg.evt) - case event.EventReaction: - portal.handleMatrixReaction(ctx, msg.user, msg.evt) - default: - log.Warn().Str("type", msg.evt.Type.Type).Msg("Unhandled matrix message type") - } -} - -func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - evtTS := time.UnixMilli(evt.Timestamp) - timings := messageTimings{ - initReceive: evt.Mautrix.ReceivedAt.Sub(evtTS), - decrypt: evt.Mautrix.DecryptionDuration, - totalReceive: time.Since(evtTS), - } - implicitRRStart := time.Now() - portal.handleMatrixReadReceipt(sender, "", uint64(evt.Timestamp), false) - timings.implicitRR = time.Since(implicitRRStart) - start := time.Now() - - messageAge := timings.totalReceive - ms := metricSender{portal: portal, timings: &timings, ctx: ctx} - log.Debug(). - Stringer("sender", evt.Sender). - Dur("age", messageAge). - Msg("Received 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(evt, errTimeoutBeforeHandling, "Timeout handling", true) - return - } else if remainingTime < 1*time.Second { - log.Warn(). - Dur("remaining_time", remainingTime). - Dur("max_timeout", errorAfter). - Msg("Message was delayed before reaching the bridge") - } - go func() { - time.Sleep(remainingTime) - ms.sendMessageMetrics(evt, errMessageTakingLong, "Timeout handling", false) - }() - } - - if deadline > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, deadline) - defer cancel() - } - - timings.preproc = time.Since(start) - start = time.Now() - - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - log.Error().Type("content_type", content).Msg("Unexpected parsed content type") - go ms.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Error converting", true) - return - } - - realSenderMXID := sender.MXID - isRelay := false - if !sender.IsLoggedIn() { - sender = portal.GetRelayUser() - if sender == nil { - go ms.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring", true) - return - } else if !sender.IsLoggedIn() { - go ms.sendMessageMetrics(evt, errRelaybotNotLoggedIn, "Ignoring", true) - return - } - isRelay = true - } - - var editTargetMsg *database.Message - if editTarget := content.RelatesTo.GetReplaceID(); editTarget != "" { - var err error - editTargetMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, editTarget) - if err != nil { - log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Failed to get edit target message") - go ms.sendMessageMetrics(evt, errFailedToGetEditTarget, "Error converting", true) - return - } else if editTargetMsg == nil { - log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Edit target message not found") - go ms.sendMessageMetrics(evt, errEditUnknownTarget, "Error converting", true) - return - } else if editTargetMsg.Sender != sender.SignalID { - go ms.sendMessageMetrics(evt, errEditDifferentSender, "Error converting", true) - return - } - if content.NewContent != nil { - content = content.NewContent - evt.Content.Parsed = content - } - } - - relaybotFormatted := isRelay && portal.addRelaybotFormat(ctx, realSenderMXID, evt, content) - if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices { - go ms.sendMessageMetrics(evt, errMNoticeDisabled, "Error converting", true) - return - } - ctx = context.WithValue(ctx, msgconvContextKeyClient, sender.Client) - msg, err := portal.MsgConv.ToSignal(ctx, evt, content, relaybotFormatted) - if err != nil { - log.Err(err).Msg("Failed to convert message") - go ms.sendMessageMetrics(evt, err, "Error converting", true) - return - } - var wrappedMsg *signalpb.Content - if editTargetMsg == nil { - wrappedMsg = &signalpb.Content{ - DataMessage: msg, - } - } else { - wrappedMsg = &signalpb.Content{ - EditMessage: &signalpb.EditMessage{ - TargetSentTimestamp: proto.Uint64(editTargetMsg.Timestamp), - DataMessage: msg, - }, - } - } - - timings.convert = time.Since(start) - start = time.Now() - - err = portal.sendSignalMessage(ctx, wrappedMsg, sender, evt.ID) - - timings.totalSend = time.Since(start) - go ms.sendMessageMetrics(evt, err, "Error sending", true) - if err == nil { - if editTargetMsg != nil { - err = editTargetMsg.SetTimestamp(ctx, msg.GetTimestamp()) - if err != nil { - log.Err(err).Msg("Failed to update message timestamp in database after editing") - } - } else { - portal.storeMessageInDB(ctx, evt.ID, sender.SignalID, msg.GetTimestamp(), 0) - if portal.ExpirationTime > 0 { - portal.addDisappearingMessage(ctx, evt.ID, uint32(portal.ExpirationTime), true) - } - } - } -} - -func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - // Find the original signal message based on eventID - dbMessage, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target message") - } - // Might be a reaction redaction, find the original message for the reaction - dbReaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target reaction") - } - - if !sender.IsLoggedIn() { - sender = portal.GetRelayUser() - if sender == nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errUserNotLoggedIn) - return - } else if !sender.IsLoggedIn() { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRelaybotNotLoggedIn) - return - } - } - - if dbMessage != nil { - if dbMessage.Sender != sender.SignalID { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRedactionTargetSentBySomeoneElse) - return - } - msg := signalmeow.DataMessageForDelete(dbMessage.Timestamp) - err = portal.sendSignalMessage(ctx, msg, sender, evt.ID) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to send message redaction to Signal") - return - } - err = dbMessage.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete redacted message from database") - } else if otherParts, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, dbMessage.Sender, dbMessage.Timestamp, portal.Receiver); err != nil { - log.Err(err).Msg("Failed to get other parts of redacted message from database") - } else if len(otherParts) > 0 { - // If there are other parts of the message, send a redaction for each of them - for _, otherPart := range otherParts { - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, otherPart.MXID, mautrix.ReqRedact{ - Reason: "Other part of Signal message redacted", - TxnID: "mxsg_partredact_" + otherPart.MXID.String(), - }) - if err != nil { - log.Err(err). - Stringer("part_event_id", otherPart.MXID). - Int("part_index", otherPart.PartIndex). - Msg("Failed to redact other part of redacted message") - } - err = otherPart.Delete(ctx) - if err != nil { - log.Err(err). - Stringer("part_event_id", otherPart.MXID). - Int("part_index", otherPart.PartIndex). - Msg("Failed to delete other part of redacted message from database") - } - } - } - portal.sendMessageStatusCheckpointSuccess(ctx, evt) - } else if dbReaction != nil { - if dbReaction.Author != sender.SignalID { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errUnreactTargetSentBySomeoneElse) - return - } - msg := signalmeow.DataMessageForReaction(dbReaction.Emoji, dbReaction.MsgAuthor, dbReaction.MsgTimestamp, true) - err = portal.sendSignalMessage(ctx, msg, sender, evt.ID) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to send reaction redaction to Signal") - return - } - err = dbReaction.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete redacted reaction from database") - } - portal.sendMessageStatusCheckpointSuccess(ctx, evt) - } else { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRedactionTargetNotFound) - } -} - -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - if !sender.IsLoggedIn() { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errCantRelayReactions) - return - } - // Find the original signal message based on eventID - relatedEventID := evt.Content.AsReaction().RelatesTo.EventID - targetMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, relatedEventID) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to get reaction target message") - return - } else if targetMsg == nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errReactionTargetNotFound) - log.Warn().Msg("Reaction target message not found") - return - } - emoji := evt.Content.AsReaction().RelatesTo.Key - signalEmoji := variationselector.FullyQualify(emoji) // Signal seems to require fully qualified emojis - msg := signalmeow.DataMessageForReaction(signalEmoji, targetMsg.Sender, targetMsg.Timestamp, false) - err = portal.sendSignalMessage(ctx, msg, sender, evt.ID) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Error().Msg("Failed to send reaction") - return - } - - // Signal only allows one reaction from each user - // Check if there's an existing reaction in the database for this sender and redact/delete it - dbReaction, err := portal.bridge.DB.Reaction.GetBySignalID( - ctx, - targetMsg.Sender, - targetMsg.Timestamp, - sender.SignalID, - portal.Receiver, - ) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction from database") - } else if dbReaction != nil { - log.Debug().Stringer("existing_event_id", dbReaction.MXID).Msg("Redacting existing reaction after sending new one") - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, dbReaction.MXID) - if err != nil { - log.Err(err).Msg("Failed to redact existing reaction") - } - } - if dbReaction != nil { - dbReaction.MXID = evt.ID - dbReaction.Emoji = signalEmoji - err = dbReaction.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update reaction in database") - } - } else { - dbReaction = portal.bridge.DB.Reaction.New() - dbReaction.MXID = evt.ID - dbReaction.RoomID = portal.MXID - dbReaction.SignalChatID = portal.ChatID - dbReaction.SignalReceiver = portal.Receiver - dbReaction.Author = sender.SignalID - dbReaction.MsgAuthor = targetMsg.Sender - dbReaction.MsgTimestamp = targetMsg.Timestamp - dbReaction.Emoji = signalEmoji - err = dbReaction.Insert(ctx) - if err != nil { - log.Err(err).Msg("Failed to insert reaction to database") - } - } - - portal.sendMessageStatusCheckpointSuccess(ctx, evt) -} - -func (portal *Portal) sendSignalMessage(ctx context.Context, msg *signalpb.Content, sender *User, evtID id.EventID) error { - log := zerolog.Ctx(ctx).With(). - Str("action", "send signal message"). - Stringer("event_id", evtID). - Str("portal_chat_id", portal.ChatID). - Logger() - ctx = log.WithContext(ctx) - - log.Debug().Msg("Sending event to Signal") - - // Check to see if portal.ChatID is a standard UUID (with dashes) - if portal.IsPrivateChat() { - // this is a 1:1 chat - result := sender.Client.SendMessage(ctx, portal.UserID(), msg) - if !result.WasSuccessful { - return result.Error - } - } else { - // this is a group chat - groupID := types.GroupIdentifier(portal.ChatID) - result, err := sender.Client.SendGroupMessage(ctx, groupID, msg) - if err != nil { - // check the start of the error string, see if it starts with "No group master key found for group identifier" - if strings.HasPrefix(err.Error(), "No group master key found for group identifier") { - portal.MainIntent().SendNotice(ctx, portal.MXID, "Missing group encryption key. Please ask a group member to send a message in this chat, then retry sending.") - } - log.Err(err).Msg("Error sending event to Signal group") - return err - } - totalRecipients := len(result.FailedToSendTo) + len(result.SuccessfullySentTo) - log = log.With(). - Int("total_recipients", totalRecipients). - Int("failed_to_send_to_count", len(result.FailedToSendTo)). - Int("successfully_sent_to_count", len(result.SuccessfullySentTo)). - Logger() - if len(result.FailedToSendTo) > 0 { - log.Error().Msg("Failed to send event to some members of Signal group") - } - if len(result.SuccessfullySentTo) == 0 && len(result.FailedToSendTo) == 0 { - log.Debug().Msg("No successes or failures - Probably sent to myself") - } else if len(result.SuccessfullySentTo) == 0 { - log.Error().Msg("Failed to send event to all members of Signal group") - return errors.New("failed to send to any members of Signal group") - - } else if len(result.SuccessfullySentTo) < totalRecipients { - log.Warn().Msg("Only sent event to some members of Signal group") - } else { - log.Debug().Msg("Sent event to all members of Signal group") - } - } - return nil -} - -func (portal *Portal) sendMessageStatusCheckpointSuccess(ctx context.Context, evt *event.Event) { - portal.sendDeliveryReceipt(ctx, evt.ID) - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - - var deliveredTo *[]id.UserID - if portal.IsPrivateChat() { - deliveredTo = &[]id.UserID{} - } - portal.sendStatusEvent(ctx, evt.ID, "", nil, deliveredTo) -} - -func (portal *Portal) sendMessageStatusCheckpointFailed(ctx context.Context, evt *event.Event, err error) { - portal.sendDeliveryReceipt(ctx, evt.ID) - portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0) - portal.sendStatusEvent(ctx, evt.ID, "", err, nil) -} - -type msgconvContextKey int - -const ( - msgconvContextKeyIntent msgconvContextKey = iota - msgconvContextKeyClient -) - -func (portal *Portal) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) { - intent := ctx.Value(msgconvContextKeyIntent).(*appservice.IntentAPI) - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: contentType, - FileName: fileName, - } - if portal.bridge.Config.Homeserver.AsyncMedia { - uploaded, err := intent.UploadAsync(ctx, req) - if err != nil { - return "", err - } - return uploaded.ContentURI.CUString(), nil - } else { - uploaded, err := intent.UploadMedia(ctx, req) - if err != nil { - return "", err - } - return uploaded.ContentURI.CUString(), nil - } -} - -func (portal *Portal) DownloadMatrixMedia(ctx context.Context, uriString id.ContentURIString) ([]byte, error) { - parsedURI, err := uriString.Parse() - if err != nil { - return nil, fmt.Errorf("malformed content URI: %w", err) - } - return portal.MainIntent().DownloadBytes(ctx, parsedURI) -} - -func (portal *Portal) GetData(ctx context.Context) *database.Portal { - return portal.Portal -} - -func (portal *Portal) GetClient(ctx context.Context) *signalmeow.Client { - return ctx.Value(msgconvContextKeyClient).(*signalmeow.Client) -} - -func (portal *Portal) GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID) { - if msg == nil { - return - } - log := zerolog.Ctx(ctx).With(). - Str("reply_target_author", msg.GetAuthorAci()). - Uint64("reply_target_ts", msg.GetId()). - Logger() - if senderUUID, err := uuid.Parse(msg.GetAuthorAci()); err != nil { - log.Err(err).Msg("Failed to parse sender UUID in Signal quote") - } else if message, err := portal.bridge.DB.Message.GetBySignalID(ctx, senderUUID, msg.GetId(), 0, portal.Receiver); err != nil { - log.Err(err).Msg("Failed to get reply target message from database") - } else if message == nil { - log.Warn().Msg("Reply target message not found") - } else { - replyTo = message.MXID - targetUser := portal.bridge.GetUserBySignalID(message.Sender) - if targetUser != nil { - replyTargetSender = targetUser.MXID - } else { - replyTargetSender = portal.bridge.FormatPuppetMXID(message.Sender) - } - } - return -} - -func (portal *Portal) GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote { - replyToID := content.RelatesTo.GetReplyTo() - if len(replyToID) == 0 { - return nil - } - 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 message from database") - } else if replyToMsg == nil { - zerolog.Ctx(ctx).Warn(). - Stringer("reply_to_mxid", replyToID). - Msg("Reply target message not found") - } else { - return &signalpb.DataMessage_Quote{ - Id: proto.Uint64(replyToMsg.Timestamp), - AuthorAci: proto.String(replyToMsg.Sender.String()), - Type: signalpb.DataMessage_Quote_NORMAL.Enum(), - - // This is a hack to make Signal iOS and desktop render replies to file messages. - // Unfortunately it also makes Signal Desktop show a file icon on replies to text messages. - // TODO store file or text flag in database and fill this field only when replying to file messages. - Attachments: make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1), - } - } - return nil -} - -func (portal *Portal) handleSignalMessage(portalMessage portalSignalMessage) { - sender := portal.bridge.GetPuppetBySignalID(portalMessage.evt.Info.Sender) - if sender == nil { - portal.log.Warn(). - Stringer("sender_uuid", portalMessage.evt.Info.Sender). - Msg("Couldn't get puppet for message") - return - } - var msgType string - var timestamp uint64 - switch typedEvt := portalMessage.evt.Event.(type) { - case *signalpb.DataMessage: - msgType = "data" - timestamp = typedEvt.GetTimestamp() - portal.handleSignalDataMessage(portalMessage.user, sender, typedEvt) - case *signalpb.TypingMessage: - msgType = "typing" - timestamp = typedEvt.GetTimestamp() - portal.handleSignalTypingMessage(sender, typedEvt) - case *signalpb.EditMessage: - msgType = "edit" - timestamp = typedEvt.GetTargetSentTimestamp() - portal.handleSignalEditMessage(sender, timestamp, typedEvt.GetDataMessage()) - default: - portal.log.Error(). - Type("data_type", typedEvt). - Msg("Invalid inner event type inside ChatEvent") - } - portal.bridge.Metrics.TrackSignalMessage(time.UnixMilli(int64(timestamp)), msgType) -} - -func (portal *Portal) handleSignalDataMessage(source *User, sender *Puppet, msg *signalpb.DataMessage) { - genericCtx := portal.log.With(). - Str("action", "handle signal data message"). - Uint64("msg_ts", msg.GetTimestamp()). - Logger().WithContext(context.TODO()) - // Always update sender info when we receive a message from them, there's caching inside the function - sender.UpdateInfo(genericCtx, source, nil) - // Handle earlier missed group changes here. - if msg.GetGroupV2() != nil { - requiredRevision := msg.GetGroupV2().GetRevision() - if msg.GetGroupV2().GetGroupChange() != nil { - requiredRevision = requiredRevision - 1 - } - if portal.Revision < requiredRevision { - err := portal.catchUpHistory(source, portal.Revision+1, requiredRevision, msg.GetTimestamp()) - if err != nil { - portal.log.Err(err).Msg("Failed to catch up group history, trying regular update") - portal.UpdateInfo(genericCtx, source, nil, msg.GetGroupV2().GetRevision()) - } - } - } else if portal.IsPrivateChat() && portal.UserID().UUID == portal.Receiver && portal.Name != NoteToSelfName { - // Slightly hacky way to make note to self names backfill - portal.UpdateDMInfo(genericCtx, false) - } - - switch { - case msgconv.CanConvertSignal(msg): - portal.handleSignalNormalDataMessage(source, sender, msg) - case msg.Reaction != nil: - portal.handleSignalReaction(sender, msg.Reaction, msg.GetTimestamp()) - case msg.Delete != nil: - portal.handleSignalDelete(sender, msg.Delete, msg.GetTimestamp()) - case msg.GetGroupV2().GetGroupChange() != nil: - portal.handleSignalGroupChange(source, sender, msg.GroupV2, msg.GetTimestamp()) - case msg.StoryContext != nil, msg.GroupCallUpdate != nil: - // ignore - default: - portal.log.Warn(). - Str("action", "handle signal message"). - Stringer("sender_uuid", sender.SignalID). - Uint64("msg_ts", msg.GetTimestamp()). - Msg("Unrecognized content in message") - } -} - -func (portal *Portal) catchUpHistory(source *User, fromRevision uint32, toRevision uint32, ts uint64) error { - log := portal.log.With(). - Str("action", "catchUpHistory"). - Stringer("source", source.MXID). - Uint32("from_revision", fromRevision). - Uint32("to_revision", toRevision). - Logger() - ctx := log.WithContext(context.TODO()) - groupChanges, err := source.Client.GetGroupHistoryPage(ctx, portal.GroupID(), fromRevision, false) - if err != nil { - log.Err(err).Msg("Failed to get GroupChanges") - return err - } - for _, groupChangeState := range groupChanges { - sender := portal.bridge.GetPuppetBySignalID(groupChangeState.GroupChange.SourceACI) - portal.applySignalGroupChange(ctx, source, sender, groupChangeState.GroupChange, ts) - // for revision > toRevision, we should have received a group change already - if groupChangeState.GroupChange.Revision == toRevision { - break - } - } - return nil -} - -func (portal *Portal) handleSignalGroupChange(source *User, sender *Puppet, groupMeta *signalpb.GroupContextV2, ts uint64) { - log := portal.log.With(). - Str("action", "handle signal group change"). - Stringer("sender_uuid", sender.SignalID). - Uint64("change_ts", ts). - Uint32("new_revision", groupMeta.GetRevision()). - Logger() - ctx := log.WithContext(context.TODO()) - groupChange, err := source.Client.DecryptGroupChange(ctx, groupMeta) - if err != nil { - log.Err(err).Msg("Handling GroupChange failed") - return - } - portal.applySignalGroupChange(ctx, source, sender, groupChange, ts) -} - -func (portal *Portal) applySignalGroupChange(ctx context.Context, source *User, sender *Puppet, groupChange *signalmeow.GroupChange, ts uint64) { - log := zerolog.Ctx(ctx) - if groupChange.Revision <= portal.Revision { - return - } - portal.Revision = groupChange.Revision - if groupChange.ModifyTitle != nil { - portal.updateName(ctx, *groupChange.ModifyTitle, sender) - } - if groupChange.ModifyDescription != nil { - portal.updateTopic(ctx, *groupChange.ModifyDescription, sender) - } - if groupChange.ModifyAvatar != nil { - portal.updateAvatarWithInfo(ctx, source, groupChange, sender) - } - if groupChange.ModifyDisappearingMessagesDuration != nil { - portal.updateExpirationTimer(ctx, *groupChange.ModifyDisappearingMessagesDuration) - } - intent := sender.IntentFor(portal) - modifyRoles := groupChange.ModifyMemberRoles - var err error - for _, deleteBannedMember := range groupChange.DeleteBannedMembers { - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, *&deleteBannedMember.UUID, event.MembershipLeave, "unbanned") - if err != nil { - log.Warn().Stringer("signal_user_id", deleteBannedMember).Msg("Couldn't get puppet for unban") - } - } - for _, addMember := range groupChange.AddMembers { - modifyRoles = append(modifyRoles, &signalmeow.RoleMember{ACI: addMember.ACI, Role: addMember.Role}) - var puppet *Puppet - if addMember.JoinFromInviteLink { - puppet = portal.bridge.GetPuppetBySignalID(addMember.ACI) - if puppet != nil { - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(addMember.ACI) - if user != nil { - portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, user.MXID, event.MembershipInvite, "Joined via invite Link") - } - } - _, err = puppet.IntentFor(portal).SendCustomMembershipEvent(ctx, portal.MXID, puppet.IntentFor(portal).UserID, event.MembershipJoin, "") - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, puppet.IntentFor(portal).UserID, event.MembershipInvite, "Joined via invite Link") - } else if err == nil { - continue - } - } - } else { - puppet, err = portal.sendMembershipForPuppetAndUser(ctx, sender, addMember.ACI, event.MembershipInvite, "added") - } - if err != nil { - log.Err(err).Stringer("signal_user_id", addMember.ACI).Msg("Couldn't get puppet for invite") - return - } - _, err = puppet.IntentFor(portal).SendCustomMembershipEvent(ctx, portal.MXID, puppet.IntentFor(portal).UserID, event.MembershipJoin, "") - if err != nil { - log.Err(err).Stringer("mxid", puppet.MXID).Msg("Failed to join user") - } - } - bannedMembers := make(map[uuid.UUID]bool) - for _, addBannedMember := range groupChange.AddBannedMembers { - if addBannedMember.ServiceID.Type == libsignalgo.ServiceIDTypePNI { - continue - } - bannedMembers[addBannedMember.ServiceID.UUID] = true - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, addBannedMember.ServiceID.UUID, event.MembershipBan, "banned") - if err != nil { - log.Warn().Stringer("signal_user_id", addBannedMember.ServiceID.UUID).Msg("Couldn't get puppet for ban") - } - } - for _, deleteMember := range groupChange.DeleteMembers { - if bannedMembers[*deleteMember] { - continue - } - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, *deleteMember, event.MembershipLeave, "deleted") - if err != nil { - log.Warn().Stringer("signal_user_id", deleteMember).Msg("Couldn't get puppet for removal") - } - } - for _, deletePendingMember := range groupChange.DeletePendingMembers { - if deletePendingMember.Type == libsignalgo.ServiceIDTypePNI { - continue - } - if bannedMembers[deletePendingMember.UUID] { - continue - } - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, deletePendingMember.UUID, event.MembershipLeave, "invite withdrawn") - if err != nil { - log.Warn().Stringer("signal_user_id", deletePendingMember).Msg("Couldn't get puppet for removal") - } - } - for _, deleteRequestingMember := range groupChange.DeleteRequestingMembers { - if bannedMembers[*deleteRequestingMember] { - continue - } - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, *deleteRequestingMember, event.MembershipLeave, "request rejected") - if err != nil { - log.Warn().Stringer("signal_user_id", deleteRequestingMember).Msg("Couldn't get puppet for removal") - } - } - for _, promotePendingMember := range groupChange.PromotePendingMembers { - puppet := portal.bridge.GetPuppetBySignalID(promotePendingMember.ACI) - if puppet == nil { - log.Warn().Stringer("signal_user_id", promotePendingMember.ACI).Msg("Couldn't get puppet for invite") - continue - } - puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - } - for _, promotePendingPniAciMember := range groupChange.PromotePendingPniAciMembers { - puppet := portal.bridge.GetPuppetBySignalID(promotePendingPniAciMember.ACI) - if puppet == nil { - log.Warn().Stringer("signal_user_id", promotePendingPniAciMember.ACI).Msg("Couldn't get puppet for invite") - continue - } - puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - } - for _, addPendingMember := range groupChange.AddPendingMembers { - if addPendingMember.ServiceID.Type == libsignalgo.ServiceIDTypePNI { - continue - } - _, err := portal.sendMembershipForPuppetAndUser(ctx, sender, addPendingMember.ServiceID.UUID, event.MembershipInvite, "invited") - if err != nil { - log.Warn().Stringer("signal_user_id", addPendingMember.ServiceID).Msg("Couldn't get puppet for invite") - } - modifyRoles = append(modifyRoles, &signalmeow.RoleMember{ACI: addPendingMember.ServiceID.UUID, Role: addPendingMember.Role}) - } - for _, promoteRequestingMember := range groupChange.PromoteRequestingMembers { - puppet, err := portal.sendMembershipForPuppetAndUser(ctx, sender, promoteRequestingMember.ACI, event.MembershipInvite, "accepted") - if err == nil { - err = puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Warn().Stringer("signal_user_id", promoteRequestingMember.ACI).Msg("failed to join puppet") - } - } else { - log.Warn().Stringer("signal_user_id", promoteRequestingMember.ACI).Msg("Couldn't get puppet for join") - } - modifyRoles = append(modifyRoles, &signalmeow.RoleMember{ACI: promoteRequestingMember.ACI, Role: promoteRequestingMember.Role}) - } - for _, addRequestingMember := range groupChange.AddRequestingMembers { - // sender and target should be the same SignalID - puppet := portal.bridge.GetPuppetBySignalID(addRequestingMember.ACI) - if puppet != nil { - portal.sendMembershipWithPuppet(ctx, sender, puppet.IntentFor(portal).UserID, event.MembershipKnock, "knocked") - } - } - - if groupChange.ModifyAttributesAccess != nil || groupChange.ModifyAnnouncementsOnly != nil || groupChange.ModifyMemberAccess != nil || len(modifyRoles) > 0 { - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Couldn't get power levels") - } else { - for _, modifyRole := range modifyRoles { - puppet := portal.bridge.GetPuppetBySignalID(modifyRole.ACI) - if puppet == nil { - log.Warn().Stringer("signal_user_id", modifyRole.ACI).Msg("Couldn't get puppet for power level change") - continue - } - powerLevel := 0 - if modifyRole.Role == signalmeow.GroupMember_ADMINISTRATOR { - powerLevel = 50 - } - levels.EnsureUserLevel(puppet.IntentFor(portal).UserID, powerLevel) - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(modifyRole.ACI) - if user != nil { - levels.EnsureUserLevel(user.MXID, powerLevel) - } - } - } - if groupChange.ModifyAnnouncementsOnly != nil { - levels.EventsDefault = 0 - if *groupChange.ModifyAnnouncementsOnly { - levels.EventsDefault = 50 - } - } - if groupChange.ModifyAttributesAccess != nil { - level := 0 - if *groupChange.ModifyAttributesAccess == signalmeow.AccessControl_ADMINISTRATOR { - level = 50 - } - levels.StateDefaultPtr = &level - } - if groupChange.ModifyMemberAccess != nil { - level := 0 - if *groupChange.ModifyMemberAccess == signalmeow.AccessControl_ADMINISTRATOR { - level = 50 - } - levels.InvitePtr = &level - } - _, err = intent.SetPowerLevels(ctx, portal.MXID, levels) - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - } - if err != nil { - log.Err(err).Msg("Couldn't set power levels") - } - } - } - if groupChange.ModifyAddFromInviteLinkAccess != nil { - joinRule := event.JoinRuleInvite - if *groupChange.ModifyAddFromInviteLinkAccess == signalmeow.AccessControl_ADMINISTRATOR { - joinRule = event.JoinRuleKnock - } else if *groupChange.ModifyAddFromInviteLinkAccess == signalmeow.AccessControl_ANY && portal.bridge.Config.Bridge.PublicPortals { - joinRule = event.JoinRulePublic - } - _, err = intent.SendMassagedStateEvent(ctx, portal.MXID, event.StateJoinRules, "", &event.JoinRulesEventContent{JoinRule: joinRule}, int64(ts)) - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().SendMassagedStateEvent(ctx, portal.MXID, event.StateJoinRules, "", &event.JoinRulesEventContent{JoinRule: joinRule}, int64(ts)) - } - if err != nil { - log.Err(err).Msg("Couldn't set join rule") - } - } - err = portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after processing group change") - } - portal.UpdateBridgeInfo(ctx) -} - -func (portal *Portal) sendMembershipForPuppetAndUser(ctx context.Context, sender *Puppet, target uuid.UUID, membership event.Membership, action string) (puppet *Puppet, err error) { - puppet = portal.bridge.GetPuppetBySignalID(target) - if puppet == nil { - err = fmt.Errorf("couldn't get Puppet for Signal uuid %s", target) - return - } - err = portal.sendMembershipWithPuppet(ctx, sender, puppet.IntentFor(portal).UserID, membership, action) - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(target) - if user != nil { - err = portal.sendMembershipWithPuppet(ctx, sender, user.MXID, membership, action) - } - } - return -} - -func (portal *Portal) sendMembershipWithPuppet(ctx context.Context, sender *Puppet, target id.UserID, membership event.Membership, action string) (err error) { - _, err = sender.IntentFor(portal).SendCustomMembershipEvent(ctx, portal.MXID, target, membership, "") - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, target, membership, fmt.Sprintf("%s by %s", action, sender.GetDisplayname())) - } - if err != nil { - zerolog.Ctx(ctx).Warn().Stringer("Membership Action failed for user", target).Msg(action) - } - return -} - -func (portal *Portal) handleSignalReaction(sender *Puppet, react *signalpb.DataMessage_Reaction, ts uint64) { - log := portal.log.With(). - Str("action", "handle signal reaction"). - Stringer("sender_uuid", sender.SignalID). - Uint64("target_msg_ts", react.GetTargetSentTimestamp()). - Str("target_msg_sender", react.GetTargetAuthorAci()). - Bool("remove", react.GetRemove()). - Logger() - ctx := log.WithContext(context.TODO()) - targetSenderUUID, err := uuid.Parse(react.GetTargetAuthorAci()) - if err != nil { - log.Err(err).Msg("Failed to parse target message sender UUID") - return - } - targetMsg, err := portal.bridge.DB.Message.GetBySignalID(ctx, targetSenderUUID, react.GetTargetSentTimestamp(), 0, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get target message from database") - return - } else if targetMsg == nil { - log.Warn().Msg("Target message not found") - return - } - existingReaction, err := portal.bridge.DB.Reaction.GetBySignalID(ctx, targetMsg.Sender, targetMsg.Timestamp, sender.SignalID, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction from database") - return - } else if existingReaction != nil && existingReaction.Emoji == react.GetEmoji() { - log.Debug().Msg("Ignoring duplicate reaction") - return - } - intent := sender.IntentFor(portal) - if existingReaction != nil { - _, err = intent.RedactEvent(ctx, portal.MXID, existingReaction.MXID, mautrix.ReqRedact{ - TxnID: "mxsg_unreact_" + existingReaction.MXID.String(), - }) - if errors.Is(err, mautrix.MForbidden) { - log.Debug().Err(err).Msg("Failed to redact reaction with ghost, retrying with main intent") - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingReaction.MXID, mautrix.ReqRedact{ - TxnID: "mxsg_unreact_" + existingReaction.MXID.String(), - }) - } - if err != nil { - log.Err(err).Msg("Failed to redact reaction") - } - if react.GetRemove() { - err = existingReaction.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to remove reaction from database after redacting") - } - return - } - } else if react.GetRemove() { - log.Warn().Msg("Existing reaction for removal not found") - return - } - // Create a new message event with the reaction - content := &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - Key: variationselector.Add(react.GetEmoji()), - EventID: targetMsg.MXID, - }, - } - resp, err := portal.sendMatrixEvent(ctx, intent, event.EventReaction, content, nil, int64(ts)) - if err != nil { - log.Err(err).Msg("Failed to send reaction") - return - } - if existingReaction == nil { - dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.MXID = resp.EventID - dbReaction.RoomID = portal.MXID - dbReaction.SignalChatID = portal.ChatID - dbReaction.SignalReceiver = portal.Receiver - dbReaction.Author = sender.SignalID - dbReaction.MsgAuthor = targetMsg.Sender - dbReaction.MsgTimestamp = targetMsg.Timestamp - dbReaction.Emoji = react.GetEmoji() - err = dbReaction.Insert(ctx) - if err != nil { - log.Err(err).Msg("Failed to insert reaction to database") - } - } else { - existingReaction.Emoji = react.GetEmoji() - existingReaction.MXID = resp.EventID - err = existingReaction.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update reaction in database") - } - } -} - -func (portal *Portal) handleSignalDelete(sender *Puppet, delete *signalpb.DataMessage_Delete, ts uint64) { - log := portal.log.With(). - Str("action", "handle signal delete"). - Stringer("sender_uuid", sender.SignalID). - Uint64("target_msg_ts", delete.GetTargetSentTimestamp()). - Uint64("delete_ts", ts). - Logger() - ctx := log.WithContext(context.TODO()) - targetMsg, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, sender.SignalID, delete.GetTargetSentTimestamp(), portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get target message from database") - return - } else if len(targetMsg) == 0 { - log.Warn().Msg("Target message not found") - return - } - intent := sender.IntentFor(portal) - for _, part := range targetMsg { - _, err = intent.RedactEvent(ctx, portal.MXID, part.MXID, mautrix.ReqRedact{ - TxnID: "mxsg_delete_" + part.MXID.String(), - }) - if err != nil { - log.Err(err). - Int("part_index", part.PartIndex). - Stringer("event_id", part.MXID). - Msg("Failed to redact message") - } - err = part.Delete(ctx) - if err != nil { - log.Err(err). - Int("part_index", part.PartIndex). - Msg("Failed to delete message from database") - } - } -} - -func (portal *Portal) handleSignalNormalDataMessage(source *User, sender *Puppet, msg *signalpb.DataMessage) { - log := portal.log.With(). - Str("action", "handle signal message"). - Stringer("sender_uuid", sender.SignalID). - Uint64("msg_ts", msg.GetTimestamp()). - Logger() - ctx := log.WithContext(context.TODO()) - if portal.MXID == "" { - log.Debug().Msg("Creating Matrix room from incoming message") - if err := portal.CreateMatrixRoom(ctx, source, msg.GetGroupV2().GetRevision()); err != nil { - log.Error().Err(err).Msg("Failed to create portal room") - return - } - } else if !portal.ensureUserInvited(ctx, source) { - log.Warn().Stringer("user_id", source.MXID).Msg("Failed to ensure source user is joined to portal") - } - - existingMessage, err := portal.bridge.DB.Message.GetBySignalID(ctx, sender.SignalID, msg.GetTimestamp(), 0, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to check if message was already bridged") - return - } else if existingMessage != nil { - log.Debug().Msg("Ignoring duplicate message") - return - } - - intent := sender.IntentFor(portal) - ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent) - converted := portal.MsgConv.ToMatrix(ctx, msg) - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - for i, part := range converted.Parts { - resp, err := portal.sendMatrixEvent(ctx, intent, part.Type, part.Content, part.Extra, int64(converted.Timestamp)) - if err != nil { - log.Err(err).Int("part_index", i).Msg("Failed to send message to Matrix") - continue - } - portal.storeMessageInDB(ctx, resp.EventID, sender.SignalID, converted.Timestamp, i) - if converted.DisappearIn != 0 { - portal.addDisappearingMessage(ctx, resp.EventID, converted.DisappearIn, sender.SignalID == source.SignalID) - // Ensure portal expiration timer is correct in DMs - if portal.implicitlyUpdateExpirationTimer(ctx, converted.DisappearIn) { - log.Info().Uint32("new_time", converted.DisappearIn).Msg("Implicitly updated expiration timer") - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after implicitly updating group info") - } - } - } - } -} - -func (portal *Portal) handleSignalEditMessage(sender *Puppet, timestamp uint64, msg *signalpb.DataMessage) { - log := portal.log.With(). - Str("action", "handle signal edit"). - Stringer("sender_uuid", sender.SignalID). - Uint64("target_msg_ts", timestamp). - Uint64("edit_msg_ts", msg.GetTimestamp()). - Logger() - if portal.MXID == "" { - log.Debug().Msg("Dropping edit message in chat with no portal") - return - } - ctx := log.WithContext(context.TODO()) - targetMessage, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, sender.SignalID, timestamp, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get target message") - return - } else if len(targetMessage) == 0 { - log.Debug().Msg("Target message not found (edit may have been already handled)") - return - } - - intent := sender.IntentFor(portal) - ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent) - converted := portal.MsgConv.ToMatrix(ctx, msg) - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - if len(converted.Parts) != len(targetMessage) { - log.Error(). - Int("target_parts", len(targetMessage)). - Int("new_parts", len(converted.Parts)). - Msg("Mismatched number of parts in edit") - return - } - for i, part := range converted.Parts { - part.Content.SetEdit(targetMessage[i].MXID) - if part.Extra != nil { - part.Extra = map[string]any{ - "m.new_content": part.Extra, - } - } - _, err = portal.sendMatrixEvent(ctx, intent, part.Type, part.Content, part.Extra, int64(converted.Timestamp)) - if err != nil { - log.Err(err).Int("part_index", i).Msg("Failed to send edit to Matrix") - } - } - err = targetMessage[0].SetTimestamp(ctx, msg.GetTimestamp()) - if err != nil { - log.Err(err).Msg("Failed to update message edit timestamp in database") - } -} - -const SignalTypingTimeout = 15 * time.Second - -func (portal *Portal) handleSignalTypingMessage(sender *Puppet, msg *signalpb.TypingMessage) { - if portal.MXID == "" { - portal.log.Debug().Msg("Dropping typing message in chat with no portal") - return - } - ctx := context.TODO() - intent := sender.IntentFor(portal) - // Don't bridge double puppeted typing notifications to avoid echoing - if intent.IsCustomPuppet { - return - } - var err error - switch msg.GetAction() { - case signalpb.TypingMessage_STARTED: - _, err = intent.UserTyping(ctx, portal.MXID, true, SignalTypingTimeout) - case signalpb.TypingMessage_STOPPED: - _, err = intent.UserTyping(ctx, portal.MXID, false, 0) - } - if err != nil { - portal.log.Err(err). - Stringer("user_id", sender.SignalID). - Msg("Failed to handle Signal typing notification") - } -} - -func (portal *Portal) storeMessageInDB(ctx context.Context, eventID id.EventID, senderSignalID uuid.UUID, timestamp uint64, partIndex int) { - dbMessage := portal.bridge.DB.Message.New() - dbMessage.MXID = eventID - dbMessage.RoomID = portal.MXID - dbMessage.Sender = senderSignalID - dbMessage.Timestamp = timestamp - dbMessage.PartIndex = partIndex - dbMessage.SignalChatID = portal.ChatID - dbMessage.SignalReceiver = portal.Receiver - err := dbMessage.Insert(ctx) - if err != nil { - portal.log.Err(err).Msg("Failed to insert message into database") - } -} - -func (portal *Portal) addDisappearingMessage(ctx context.Context, eventID id.EventID, expireInSeconds uint32, startTimerNow bool) { - portal.bridge.disappearingMessagesManager.AddDisappearingMessage(ctx, eventID, portal.MXID, time.Duration(expireInSeconds)*time.Second, startTimerNow) -} - -func (portal *Portal) MarkDelivered(ctx context.Context, msg *database.Message) { - if !portal.IsPrivateChat() { - return - } - portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{ - EventID: msg.MXID, - RoomID: portal.MXID, - Step: status.MsgStepRemote, - Timestamp: jsontime.UnixMilliNow(), - Status: status.MsgStatusDelivered, - ReportedBy: status.MsgReportedByBridge, - }) - portal.sendStatusEvent(ctx, msg.MXID, "", nil, &[]id.UserID{portal.MainIntent().UserID}) -} - -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 (portal *Portal) SendReadReceipt(ctx context.Context, sender *Puppet, msg *database.Message) error { - intent := sender.IntentFor(portal) - if intent.IsCustomPuppet { - extra := customReadReceipt{DoublePuppetSource: portal.bridge.Name} - return intent.SetReadMarkers(ctx, portal.MXID, &customReadMarkers{ - ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ - Read: msg.MXID, - FullyRead: msg.MXID, - }, - ReadExtra: extra, - FullyReadExtra: extra, - }) - } else { - return intent.MarkRead(ctx, portal.MXID, msg.MXID) - } -} - -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, isTyping bool) { - for _, userID := range userIDs { - user := portal.bridge.GetUserByMXID(userID) - if user == nil || !user.IsLoggedIn() { - continue - } - - // Check to see if portal.ChatID is a standard UUID (with dashes) - // Note: not handling sending to a group right now, since that will - // require SenderKey sending to not be terrible - dmUserID := portal.UserID() - if !dmUserID.IsEmpty() && dmUserID.Type == libsignalgo.ServiceIDTypeACI { - // this is a 1:1 chat - portal.log.Debug().Msg("Sending Typing event to Signal") - ctx := context.TODO() - typingMessage := signalmeow.TypingMessage(isTyping) - result := user.Client.SendMessage(ctx, portal.UserID(), typingMessage) - if !result.WasSuccessful { - portal.log.Err(result.FailedSendResult.Error).Msg("Error sending event to Signal") - } - } - } -} - -func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) { - if portal.IsNoteToSelf() { - return - } - - portal.currentlyTypingLock.Lock() - defer portal.currentlyTypingLock.Unlock() - startedTyping, stoppedTyping := typingDiff(portal.currentlyTyping, newTyping) - portal.currentlyTyping = newTyping - portal.setTyping(startedTyping, true) - portal.setTyping(stoppedTyping, false) -} - -func (portal *Portal) HandleMatrixReadReceipt(brSender bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - portal.handleMatrixReadReceipt(brSender.(*User), eventID, uint64(receipt.Timestamp.UnixMilli()), true) -} - -func (portal *Portal) handleMatrixReadReceipt(sender *User, eventID id.EventID, maxTimestamp uint64, isExplicit bool) { - if !sender.IsLoggedIn() { - return - } - logWith := portal.log.With(). - Stringer("event_id", eventID). - Stringer("sender", sender.MXID). - Bool("explicit", isExplicit) - if isExplicit { - logWith = logWith.Str("action", "handle matrix read receipt") - } - log := logWith.Logger() - log.Debug().Msg("Handling Matrix read receipt") - portal.ScheduleDisappearing() - ctx := log.WithContext(context.TODO()) - - if isExplicit { - dbMessage, _ := portal.bridge.DB.Message.GetByMXID(ctx, eventID) - if dbMessage != nil { - maxTimestamp = dbMessage.Timestamp - } - } - prevLastReadTS := sender.GetLastReadTS(ctx, portal.PortalKey) - if maxTimestamp <= prevLastReadTS { - log.Debug(). - Uint64("prev_last_read_ts", prevLastReadTS). - Uint64("max_timestamp", maxTimestamp). - Msg("Ignoring read receipt older than last read timestamp") - return - } - minTimestamp := prevLastReadTS - if minTimestamp == 0 { - minTimestamp = maxTimestamp - 2000 - } - dbMessages, err := portal.bridge.DB.Message.GetAllBetweenTimestamps(ctx, portal.PortalKey, minTimestamp, maxTimestamp) - if err != nil { - log.Err(err).Msg("Failed to get messages between timestamps to mark as read") - return - } - messagesToRead := map[uuid.UUID][]uint64{} - for _, msg := range dbMessages { - messagesToRead[msg.Sender] = append(messagesToRead[msg.Sender], msg.Timestamp) - } - // Always update last read ts for non-explicit read receipts, because that means there's a message about to be sent - if (len(dbMessages) > 0 || !isExplicit) && maxTimestamp != prevLastReadTS { - sender.SetLastReadTS(ctx, portal.PortalKey, maxTimestamp) - } - if isExplicit || len(messagesToRead) > 0 { - log.Debug(). - Any("targets", messagesToRead). - Uint64("prev_last_read_ts", prevLastReadTS). - Uint64("min_timestamp", minTimestamp). - Uint64("max_timestamp", maxTimestamp). - Msg("Collected read receipt target messages") - } - - // TODO send sync message manually containing all read receipts instead of a separate message for each recipient - - for destination, messages := range messagesToRead { - // Don't send read receipts for own messages - if destination == sender.SignalID { - continue - } - // Don't use portal.sendSignalMessage because we're sending this straight to - // who sent the original message, not the portal's ChatID - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - result := sender.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(destination), signalmeow.ReadReceptMessageForTimestamps(messages)) - cancel() - if !result.WasSuccessful { - log.Err(result.FailedSendResult.Error). - Stringer("destination", destination). - Uints64("message_ids", messages). - Msg("Failed to send read receipt to Signal") - } else { - log.Debug(). - Stringer("destination", destination). - Uints64("message_ids", messages). - Msg("Sent read receipt to Signal") - } - } -} - -func (portal *Portal) sendMainIntentMessage(ctx context.Context, content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { - return portal.sendMatrixEvent(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) sendMatrixEvent(ctx context.Context, intent *appservice.IntentAPI, eventType event.Type, content any, extraContent map[string]any, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := event.Content{Parsed: content, Raw: extraContent} - if eventType != event.EventReaction { - var err error - eventType, err = portal.encrypt(ctx, intent, &wrappedContent, eventType) - if err != nil { - return nil, err - } - } - - _, _ = intent.UserTyping(ctx, portal.MXID, false, 0) - return intent.SendMassagedMessageEvent(ctx, portal.MXID, eventType, &wrappedContent, timestamp) -} - -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) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") -} - -func (portal *Portal) ensureUserInvited(ctx context.Context, user *User) bool { - return user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) -} - -func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupRevision uint32) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - portal.log.Debug().Msg("Not creating room: already exists") - return nil - } - portal.log.Debug().Msg("Creating matrix room") - - intent := portal.MainIntent() - - if err := intent.EnsureRegistered(ctx); err != nil { - portal.log.Error().Err(err).Msg("failed to ensure registered") - return err - } - - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - initialState := []*event.Event{{ - 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, - }} - - if !portal.AvatarURL.IsEmpty() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{Parsed: &event.RoomAvatarEventContent{ - URL: portal.AvatarURL.CUString(), - }}, - }) - } - - creationContent := make(map[string]interface{}) - if !portal.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - - var invite []id.UserID - autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) - if autoJoinInvites { - invite = append(invite, user.MXID) - } - - 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() && portal.MainIntent() != portal.bridge.Bot { - invite = append(invite, portal.bridge.Bot.UserID) - } - } - - var dmPuppet *Puppet - var groupInfo *signalmeow.Group - if portal.IsPrivateChat() { - dmPuppet = portal.GetDMPuppet() - if dmPuppet != nil { - dmPuppet.UpdateInfo(ctx, user, nil) - portal.UpdateDMInfo(ctx, false) - } else { - portal.UpdatePNIDMInfo(ctx, user) - } - } else { - groupInfo = portal.UpdateGroupInfo(ctx, user, nil, groupRevision, true) - if groupInfo == nil { - portal.log.Error().Msg("Didn't get group info after updating portal") - return errors.New("failed to get group info") - } - for member := range portal.SyncParticipants(ctx, user, groupInfo) { - invite = append(invite, member) - } - } - - 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, - } - resp, err := intent.CreateRoom(ctx, req) - if err != nil { - portal.log.Warn().Err(err).Msg("failed to create room") - return err - } - portal.log = portal.log.With().Stringer("room_id", resp.RoomID).Logger() - - portal.NameSet = len(req.Name) > 0 - portal.TopicSet = len(req.Topic) > 0 - portal.AvatarSet = !portal.AvatarURL.IsEmpty() - portal.MXID = resp.RoomID - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - err = portal.Update(ctx) - if err != nil { - portal.log.Err(err).Msg("Failed to save portal room ID") - return err - } - portal.log.Info().Msg("Created matrix room for portal") - - if !autoJoinInvites { - if !portal.IsPrivateChat() { - portal.SyncParticipants(ctx, user, groupInfo) - } else if portal.Encrypted { - err = portal.bridge.Bot.EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) - if err != nil { - portal.log.Error().Err(err).Msg("Failed to ensure bridge bot is joined to private chat portal") - } - } - user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) - } - user.syncChatDoublePuppetDetails(portal, true) - go portal.addToPersonalSpace(portal.log.WithContext(context.TODO()), user) - - if dmPuppet != nil { - user.UpdateDirectChats(ctx, map[id.UserID][]id.RoomID{ - dmPuppet.MXID: {portal.MXID}, - }) - } - - return nil -} - -func (portal *Portal) GetDMPuppet() *Puppet { - userID := portal.UserID() - if userID.IsEmpty() || userID.Type != libsignalgo.ServiceIDTypeACI { - return nil - } - return portal.bridge.GetPuppetBySignalID(userID.UUID) -} - -func (portal *Portal) UpdateInfo(ctx context.Context, source *User, groupInfo *signalmeow.Group, revision uint32) { - if portal.IsPrivateChat() { - portal.UpdateDMInfo(ctx, false) - return - } - groupInfo = portal.UpdateGroupInfo(ctx, source, groupInfo, revision, false) - if groupInfo != nil { - members := portal.SyncParticipants(ctx, source, groupInfo) - portal.updatePowerLevelsAndJoinRule(ctx, groupInfo, members) - } -} - -const PrivateChatTopic = "Signal private chat" -const NoteToSelfName = "Signal Note to Self" - -func (portal *Portal) PostReIDUpdate(ctx context.Context, user *User) { - _, err := portal.bridge.Bot.SetPowerLevel(ctx, portal.MXID, portal.MainIntent().UserID, 100) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update ghost power level after portal re-ID") - } - portal.GetDMPuppet().UpdateInfo(ctx, user, nil) - portal.UpdateDMInfo(ctx, true) - if !portal.Encrypted { - _, _ = portal.bridge.Bot.LeaveRoom(ctx, portal.MXID) - } -} - -func (portal *Portal) UpdateDMInfo(ctx context.Context, forceSave bool) { - log := zerolog.Ctx(ctx).With(). - Str("function", "UpdateDMInfo"). - Logger() - log.Trace().Msg("Updating portal info") - ctx = log.WithContext(ctx) - puppet := portal.GetDMPuppet() - - update := forceSave - if portal.UserID().UUID == portal.Receiver { - noteToSelfAvatar := portal.bridge.Config.Bridge.NoteToSelfAvatar.ParseOrIgnore() - avatarHash := sha256.Sum256([]byte(noteToSelfAvatar.String())) - - update = portal.updateName(ctx, NoteToSelfName, nil) || update - update = portal.updateAvatarWithMXC(ctx, "notetoself", hex.EncodeToString(avatarHash[:]), noteToSelfAvatar) || update - } else if portal.shouldSetDMRoomMetadata() { - update = portal.updateName(ctx, puppet.Name, nil) || update - update = portal.updateAvatarWithMXC(ctx, puppet.AvatarPath, puppet.AvatarHash, puppet.AvatarURL) || update - } else { - // Clear name/avatar if they're set in a DM that shouldn't have them set - if portal.Name != "" && portal.NameSet { - update = portal.updateName(ctx, "", nil) || update - } - // Avatar is currently never set in PNI portals - //if !portal.AvatarURL.IsEmpty() && portal.AvatarSet { - // update = true - // portal.AvatarURL = id.ContentURI{} - // portal.AvatarHash = "" - // portal.AvatarPath = "" - // portal.updateAvatarInRoom(ctx, nil) - //} - } - topic := PrivateChatTopic - if portal.bridge.Config.Bridge.NumberInTopic && puppet.Number != "" { - topic = fmt.Sprintf("%s with %s", topic, puppet.Number) - } - update = portal.updateTopic(ctx, topic, nil) || update - if update { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after updating group info") - } - portal.UpdateBridgeInfo(ctx) - } -} - -func (portal *Portal) UpdatePNIDMInfo(ctx context.Context, user *User) { - portalUserID := portal.UserID() - if portalUserID.Type != libsignalgo.ServiceIDTypePNI { - return - } - log := zerolog.Ctx(ctx) - update := false - recipient, err := user.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, uuid.Nil, portalUserID.UUID, nil) - if err != nil { - log.Err(err).Msg("Failed to get PNI DM recipient entry") - } - if recipient == nil { - recipient = &types.Recipient{PNI: portalUserID.UUID} - } - topic := PrivateChatTopic - name := portalUserID.UUID.String() - if recipient.E164 != "" { - topic = fmt.Sprintf("%s with %s", topic, recipient.E164) - name = recipient.E164 - } - if recipient.ContactName != "" { - name = recipient.ContactName - } - update = portal.updateTopic(ctx, topic, nil) || update - update = portal.updateName(ctx, name, nil) || update - if update { - err = portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after updating group info") - } - portal.UpdateBridgeInfo(ctx) - } -} - -func (portal *Portal) updatePowerLevelsAndJoinRule(ctx context.Context, info *signalmeow.Group, members map[id.UserID]int) { - log := zerolog.Ctx(ctx).With(). - Str("function", "updatePowerLevelsAndJoinRule"). - Logger() - log.Trace().Msg("Updating power levels and join rule") - joinRuleContent := event.JoinRulesEventContent{} - err := portal.MainIntent().StateEvent(ctx, portal.MXID, event.StateJoinRules, "", &joinRuleContent) - if err != nil { - log.Err(err).Msg("Failed to get join rule") - return - } - joinRule := joinRuleContent.JoinRule - newJoinRule := event.JoinRuleInvite - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Failed to get power levels") - return - } - botLevel := levels.GetUserLevel(portal.MainIntent().UserID) - changed := false - for mxid, level := range members { - oldLevel := levels.GetUserLevel(mxid) - difference := oldLevel - level - if oldLevel < botLevel && (difference < 0 || difference > 49) { - changed = levels.EnsureUserLevel(mxid, level) || changed - } - } - newEventsDefault := 0 - if info.AnnouncementsOnly { - newEventsDefault = 50 - } - if newEventsDefault != levels.EventsDefault { - levels.EventsDefault = newEventsDefault - changed = true - } - if info.AccessControl != nil { - level := 0 - if info.AccessControl.Attributes == signalmeow.AccessControl_ADMINISTRATOR { - level = 50 - } - changed = levels.EnsureEventLevel(event.StateRoomName, level) || changed - changed = levels.EnsureEventLevel(event.StateTopic, level) || changed - changed = levels.EnsureEventLevel(event.StateRoomAvatar, level) || changed - level = 0 - if info.AccessControl.Members == signalmeow.AccessControl_ADMINISTRATOR { - level = 50 - } - if levels.InvitePtr == nil || *levels.InvitePtr != level { - levels.InvitePtr = &level - changed = true - } - if info.AccessControl.AddFromInviteLink == signalmeow.AccessControl_ADMINISTRATOR { - newJoinRule = event.JoinRuleKnock - } else if info.AccessControl.AddFromInviteLink == signalmeow.AccessControl_ANY && (portal.bridge.Config.Bridge.PublicPortals || joinRule == event.JoinRulePublic) { - newJoinRule = event.JoinRulePublic - } - } - if newJoinRule != joinRule { - _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateJoinRules, "", &event.JoinRulesEventContent{JoinRule: joinRule}) - if err != nil { - log.Err(err).Msg("Failed to set join rule") - } - } - if changed { - _, err = portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels) - if err != nil { - log.Err(err).Msg("Failed to set power levels") - } - } -} - -func (portal *Portal) UpdateGroupInfo(ctx context.Context, source *User, info *signalmeow.Group, revision uint32, forceFetch bool) *signalmeow.Group { - logWith := zerolog.Ctx(ctx).With(). - Str("function", "UpdateGroupInfo"). - Uint32("revision", revision). - Stringer("source_user_mxid", source.MXID) - if info != nil { - logWith = logWith.Uint32("info_revision", info.Revision) - } - log := logWith.Logger() - if info == nil { - if revision <= portal.Revision && !forceFetch { - log.Debug().Msg("Not fetching group info to update portal: given revision is not newer") - return nil - } - log.Debug().Msg("Fetching group info to update portal") - var err error - info, err = source.Client.RetrieveGroupByID(ctx, portal.GroupID(), revision) - if err != nil { - log.Err(err). - Stringer("source_user_id", source.MXID). - Msg("Failed to fetch group info") - return nil - } - } - if portal.Revision > info.Revision { - log.Debug().Uint32("current_revision", portal.Revision).Msg("Not updating portal with data from older revision") - return info - } - logEvt := log.Trace() - if portal.Revision != info.Revision { - logEvt = log.Debug() - } - logEvt.Uint32("current_revision", portal.Revision).Msg("Updating portal info") - ctx = log.WithContext(ctx) - update := false - if portal.Revision < info.Revision { - portal.Revision = info.Revision - update = true - } - update = portal.updateName(ctx, info.Title, nil) || update - update = portal.updateTopic(ctx, info.Description, nil) || update - update = portal.updateAvatarWithInfo(ctx, source, info, nil) || update - update = portal.updateExpirationTimer(ctx, info.DisappearingMessagesDuration) || update - if update { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after updating group info") - } - portal.UpdateBridgeInfo(ctx) - } - return info -} - -func (portal *Portal) updateExpirationTimer(ctx context.Context, newExpirationTimer uint32) bool { - if portal.ExpirationTime == newExpirationTimer { - return false - } - portal.ExpirationTime = newExpirationTimer - if portal.MXID != "" { - msg := portal.MsgConv.ConvertDisappearingTimerChangeToMatrix(ctx, newExpirationTimer, false) - _, err := portal.sendMainIntentMessage(ctx, msg.Content) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send notice about disappearing message timer changing") - } - } - return true -} - -func (portal *Portal) implicitlyUpdateExpirationTimer(ctx context.Context, newExpirationTimer uint32) bool { - if portal.ExpirationTime == newExpirationTimer { - return false - } - portal.ExpirationTime = newExpirationTimer - if portal.MXID != "" { - msg := portal.MsgConv.ConvertDisappearingTimerChangeToMatrix(ctx, newExpirationTimer, false) - msg.Content.Body = fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", exfmt.Duration(time.Duration(newExpirationTimer)*time.Second)) - _, err := portal.sendMainIntentMessage(ctx, msg.Content) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send notice about disappearing message timer changing implicitly") - } - } - return true -} - -func (portal *Portal) updateName(ctx context.Context, newName string, sender *Puppet) bool { - if portal.Name == newName && (portal.NameSet || portal.MXID == "") { - return false - } - portal.Name = newName - portal.NameSet = false - if portal.MXID != "" { - intent := portal.MainIntent() - if sender != nil { - intent = sender.IntentFor(portal) - } - _, err := intent.SetRoomName(ctx, portal.MXID, portal.Name) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - _, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name) - } - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update room name") - } else { - portal.NameSet = true - } - } - return true -} - -func (portal *Portal) updateTopic(ctx context.Context, newTopic string, sender *Puppet) bool { - if portal.Topic == newTopic && (portal.TopicSet || portal.MXID == "") { - return false - } - portal.Topic = newTopic - portal.TopicSet = false - if portal.MXID != "" { - intent := portal.MainIntent() - if sender != nil { - intent = sender.IntentFor(portal) - } - _, err := intent.SetRoomTopic(ctx, portal.MXID, portal.Topic) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - _, err = portal.MainIntent().SetRoomTopic(ctx, portal.MXID, portal.Topic) - } - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update room topic") - } else { - portal.TopicSet = true - } - } - return true -} - -func (portal *Portal) updateAvatarWithInfo(ctx context.Context, source *User, group signalmeow.GroupAvatarMeta, sender *Puppet) bool { - // If the avatar path is different, the avatar probably changed - avatarPath := group.GetAvatarPath() - if avatarPath == nil { - return false - } - if portal.AvatarPath == *avatarPath && - // If the avatar mxc isn't set, we need to reupload it (except if the avatar is unset in Signal) - (!portal.AvatarURL.IsEmpty() || *avatarPath == "") && - // If the avatar isn't set in the room, we need to update the room state (except if there's no Matrix room yet) - (portal.AvatarSet || portal.MXID == "") { - return false - } - if *avatarPath == "" { - portal.AvatarPath = "" - portal.AvatarSet = false - portal.AvatarURL = id.ContentURI{} - portal.AvatarHash = "" - // Just clear the Matrix room avatar and return - portal.updateAvatarInRoom(ctx, sender) - return true - } - log := zerolog.Ctx(ctx) - log.Debug().Str("avatar_path", portal.AvatarPath).Msg("Downloading new group avatar from Signal") - avatarBytes, err := source.Client.DownloadGroupAvatar(ctx, group) - if err != nil { - log.Err(err).Msg("Failed to download new avatar for portal") - return true - } - hash := sha256.Sum256(avatarBytes) - newAvatarHash := hex.EncodeToString(hash[:]) - if portal.AvatarHash == newAvatarHash && (portal.AvatarSet || portal.MXID == "") { - // No need to change anything else, but save the new path to the database - return true - } - portal.AvatarPath = *avatarPath - portal.AvatarSet = false - portal.AvatarURL = id.ContentURI{} - portal.AvatarHash = newAvatarHash - log.Debug().Str("avatar_hash", portal.AvatarHash).Msg("Uploading new group avatar to Matrix") - resp, err := portal.MainIntent().UploadBytes(ctx, avatarBytes, http.DetectContentType(avatarBytes)) - if err != nil { - log.Err(err).Msg("Failed to upload new avatar for portal") - } else { - portal.AvatarURL = resp.ContentURI - portal.updateAvatarInRoom(ctx, sender) - } - return true -} - -func (portal *Portal) updateAvatarWithMXC(ctx context.Context, newAvatarPath, newAvatarHash string, newAvatarURI id.ContentURI) bool { - if portal.AvatarHash == newAvatarHash && (portal.AvatarSet || portal.MXID == "") { - return false - } - portal.AvatarPath = newAvatarPath - portal.AvatarHash = newAvatarHash - portal.AvatarURL = newAvatarURI - portal.AvatarSet = false - portal.updateAvatarInRoom(ctx, nil) - return true -} - -func (portal *Portal) updateAvatarInRoom(ctx context.Context, sender *Puppet) { - if portal.MXID == "" || portal.AvatarSet { - return - } - zerolog.Ctx(ctx).Debug(). - Str("avatar_path", portal.AvatarPath). - Str("avatar_hash", portal.AvatarHash). - Stringer("avatar_mxc", portal.AvatarURL). - Msg("Updating avatar in Matrix room") - intent := portal.MainIntent() - if sender != nil { - intent = sender.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 { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update room avatar") - } else { - portal.AvatarSet = true - } -} - -func (portal *Portal) SyncParticipants(ctx context.Context, source *User, info *signalmeow.Group) map[id.UserID]int { - log := zerolog.Ctx(ctx) - userIDs := make(map[id.UserID]int) - currentMembers := make(map[id.UserID]event.Membership) - var err error - if portal.MXID != "" { - memberEventData, err := portal.MainIntent().Members(ctx, portal.MXID, mautrix.ReqMembers{}) - if err != nil { - log.Err(err).Msg("couldn't get portal members") - return nil - } - for _, evt := range memberEventData.Chunk { - evt.Content.ParseRaw(event.StateMember) - currentMembers[id.UserID(*evt.StateKey)] = evt.Content.AsMember().Membership - } - } - for _, member := range info.Members { - puppet := portal.bridge.GetPuppetBySignalID(member.ACI) - if puppet == nil { - log.Warn().Stringer("signal_user_id", member.ACI).Msg("Couldn't get puppet for group member") - continue - } - puppet.UpdateInfo(ctx, source, nil) - intent := puppet.IntentFor(portal) - if member.ACI != source.SignalID && portal.MXID != "" { - userIDs[intent.UserID] = ((int)(member.Role) >> 1) * 50 - } - delete(currentMembers, intent.UserID) - if portal.MXID != "" { - if currentMembers[intent.UserID] != event.MembershipJoin { - err := intent.EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Err(err).Stringer("signal_user_id", member.ACI).Msg("Failed to ensure user is joined to portal") - } - } - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(member.ACI) - if user != nil { - delete(currentMembers, user.MXID) - userIDs[user.MXID] = ((int)(member.Role) >> 1) * 50 - currentMembership := currentMembers[user.MXID] - if currentMembership == event.MembershipJoin || currentMembership == event.MembershipInvite { - continue - } - user.ensureInvited(ctx, intent, portal.MXID, false) - } - } - } - } - if portal.MXID == "" { - return userIDs - } - for _, pendingMember := range info.PendingMembers { - if pendingMember.ServiceID.Type == libsignalgo.ServiceIDTypePNI { - continue - } - puppet := portal.bridge.GetPuppetBySignalID(pendingMember.ServiceID.UUID) - if puppet == nil { - log.Warn().Stringer("signal_user_id", pendingMember.ServiceID.UUID).Msg("Couldn't get puppet for group member") - continue - } - mxid := puppet.IntentFor(portal).UserID - membership := currentMembers[mxid] - if membership == event.MembershipJoin || membership == event.MembershipBan { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipLeave, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to leave") - } - } - if membership != event.MembershipInvite { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipInvite, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to invite") - } - } - userIDs[mxid] = ((int)(pendingMember.Role) >> 1) * 50 - delete(currentMembers, mxid) - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(pendingMember.ServiceID.UUID) - if user == nil { - continue - } - mxid = user.MXID - membership := currentMembers[mxid] - err = nil - if membership == event.MembershipJoin || membership == event.MembershipBan { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipLeave, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to leave") - } - } - if membership != event.MembershipInvite { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipInvite, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to invite") - } - } - userIDs[mxid] = ((int)(pendingMember.Role) >> 1) * 50 - delete(currentMembers, mxid) - } - } - for _, requestingMember := range info.RequestingMembers { - puppet := portal.bridge.GetPuppetBySignalID(requestingMember.ACI) - if puppet == nil { - log.Warn().Stringer("signal_user_id", requestingMember.ACI).Msg("Couldn't get puppet for group member") - continue - } - mxid := puppet.IntentFor(portal).UserID - membership := currentMembers[mxid] - if membership == event.MembershipJoin || membership == event.MembershipBan { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipLeave, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to leave") - } - } - if membership != event.MembershipKnock { - _, err = puppet.IntentFor(portal).SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipKnock, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to knock") - } - } - delete(currentMembers, mxid) - } - for _, bannedMember := range info.BannedMembers { - if bannedMember.ServiceID.Type == libsignalgo.ServiceIDTypePNI { - continue - } - puppet := portal.bridge.GetPuppetBySignalID(bannedMember.ServiceID.UUID) - if puppet == nil { - log.Warn().Stringer("signal_user_id", bannedMember.ServiceID.UUID).Msg("Couldn't get puppet for group member") - continue - } - mxid := puppet.IntentFor(portal).UserID - if currentMembers[mxid] != event.MembershipBan { - _, err := portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipBan, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to ban") - } - } - delete(currentMembers, mxid) - if puppet.customIntent == nil { - user := portal.bridge.GetUserBySignalID(bannedMember.ServiceID.UUID) - if user == nil { - continue - } - mxid = user.MXID - if currentMembers[mxid] != event.MembershipBan { - _, err = portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipBan, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to ban") - } - } - delete(currentMembers, mxid) - } - } - for mxid, membership := range currentMembers { - if membership == event.MembershipLeave { - continue - } - puppet := portal.bridge.GetPuppetByMXID(mxid) - if puppet != nil { - _, err := portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipLeave, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to leave") - } - } else { - user := portal.bridge.GetUserByMXIDIfExists(mxid) - if user != nil { - if user.IsLoggedIn() { - _, err := portal.MainIntent().SendCustomMembershipEvent(ctx, portal.MXID, mxid, event.MembershipLeave, "") - if err != nil { - log.Err(err).Stringer("mxid", mxid).Msg("Couldn't change membership to leave") - } - } - } - } - } - return userIDs -} - -func (portal *Portal) getBridgeInfoStateKey() string { - return fmt.Sprintf("net.maunium.signal://signal/%s", portal.ChatID) -} - -func (portal *Portal) ScheduleDisappearing() { - portal.bridge.disappearingMessagesManager.ScheduleDisappearingForRoom(context.TODO(), portal.MXID) -} - -func (portal *Portal) addToPersonalSpace(ctx context.Context, user *User) bool { - spaceID := user.GetSpaceRoom(ctx) - if len(spaceID) == 0 || user.IsInSpace(ctx, portal.PortalKey) { - return false - } - _, 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("user_id", user.MXID). - Stringer("space_id", spaceID). - Msg("Failed to add room to user's personal filtering space") - return false - } else { - zerolog.Ctx(ctx).Debug(). - Stringer("user_id", user.MXID). - Stringer("space_id", spaceID). - Msg("Added room to user's personal filtering space") - user.MarkInSpace(ctx, portal.PortalKey) - return true - } -} - -func (portal *Portal) HasRelaybot() bool { - return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0 -} - -func (portal *Portal) addRelaybotFormat(ctx context.Context, userID id.UserID, evt *event.Event, content *event.MessageEventContent) bool { - member := portal.MainIntent().Member(ctx, portal.MXID, userID) - if member == nil { - member = &event.MemberEventContent{} - } - // Stickers can't have captions, so force them into images when relaying - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - evt.Type = event.EventMessage - } - content.EnsureHasHTML() - data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member) - if err != nil { - portal.log.Err(err).Msg("Failed to apply relaybot format") - } - content.FormattedBody = data - // Force FileName field so the formatted body is used as a caption - if content.FileName == "" { - content.FileName = content.Body - } - return true -} - -func (portal *Portal) Delete() { - err := portal.Portal.Delete(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to delete portal from db") - } - portal.bridge.portalsLock.Lock() - portal.unlockedDeleteCache() - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) unlockedDelete() { - err := portal.Portal.Delete(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to delete portal from db") - } - portal.unlockedDeleteCache() -} - -func (portal *Portal) unlockedDeleteCache() { - delete(portal.bridge.portalsByID, portal.PortalKey) - if len(portal.MXID) > 0 { - delete(portal.bridge.portalsByMXID, portal.MXID) - } - if portal.Receiver == uuid.Nil { - portal.bridge.usersLock.Lock() - for _, user := range portal.bridge.usersBySignalID { - user.RemoveInSpaceCache(portal.PortalKey) - } - portal.bridge.usersLock.Unlock() - } else { - user := portal.bridge.GetUserBySignalID(portal.Receiver) - if user != nil { - user.RemoveInSpaceCache(portal.PortalKey) - } - } -} - -func (portal *Portal) Cleanup(ctx context.Context, puppetsOnly bool) { - portal.bridge.CleanupRoom(ctx, &portal.log, portal.MainIntent(), portal.MXID, puppetsOnly) -} - -func (br *SignalBridge) CleanupRoom(ctx context.Context, log *zerolog.Logger, intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool) { - if len(mxid) == 0 { - return - } - if br.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := intent.BeeperDeleteRoom(ctx, 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, 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 := br.GetPuppetByMXID(member) - if puppet != nil { - _, err = puppet.DefaultIntent().LeaveRoom(ctx, mxid) - if err != nil { - log.Err(err).Msg("Failed to leave as puppet while cleaning up portal") - } - } else if !puppetsOnly { - _, err = intent.KickUser(ctx, mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - log.Err(err).Msg("Failed to kick user while cleaning up portal") - } - } - } - _, err = intent.LeaveRoom(ctx, mxid) - if err != nil { - log.Err(err).Msg("Failed to leave room while cleaning up portal") - } -} - -func (portal *Portal) HandleMatrixLeave(brSender bridge.User, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix leave"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - if portal.IsPrivateChat() { - log.Info().Msg("User left private chat portal, cleaning up and deleting...") - portal.Delete() - portal.Cleanup(ctx, false) - return - } else if portal.bridge.Config.Bridge.BridgeMatrixLeave { - portal.deleteMember(sender, sender.SignalID, evt) - } - portal.CleanupIfEmpty(ctx) -} -func (portal *Portal) HandleMatrixKick(brSender bridge.User, ghost bridge.Ghost, evt *event.Event) { - portal.deleteMember(brSender.(*User), ghost.(*Puppet).SignalID, evt) -} -func (portal *Portal) deleteMember(sender *User, target uuid.UUID, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix kick/leave"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - groupChange := &signalmeow.GroupChange{DeleteMembers: []*uuid.UUID{&target}} - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error deleting Member from Signal") - return - } - portal.Revision = revision - portal.Update(ctx) -} -func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix invite"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - puppet := brGhost.(*Puppet) - role := signalmeow.GroupMember_DEFAULT - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Couldn't get power levels") - if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 { - role = signalmeow.GroupMember_ADMINISTRATOR - } - } - groupChange := &signalmeow.GroupChange{AddMembers: []*signalmeow.AddMember{{ - GroupMember: signalmeow.GroupMember{ - ACI: puppet.SignalID, - Role: role, - }, - }}} - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error inviting user on Signal") - } - puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixAcceptKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix accept knock"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - puppet := brGhost.(*Puppet) - role := signalmeow.GroupMember_DEFAULT - levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) - if err != nil { - log.Err(err).Msg("Couldn't get power levels") - if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 { - role = signalmeow.GroupMember_ADMINISTRATOR - } - } - groupChange := &signalmeow.GroupChange{PromoteRequestingMembers: []*signalmeow.RoleMember{{ - ACI: puppet.SignalID, - Role: role, - }}} - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error accepting join request on Signal") - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixRejectKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { - portal.removeRequestingMember(brSender.(*User), brGhost.(*Puppet).SignalID, evt) -} - -func (portal *Portal) HandleMatrixRetractKnock(brSender bridge.User, evt *event.Event) { - portal.removeRequestingMember(brSender.(*User), brSender.(*User).SignalID, evt) -} - -func (portal *Portal) removeRequestingMember(sender *User, target uuid.UUID, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix knock -> leave"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - groupChange := &signalmeow.GroupChange{DeleteRequestingMembers: []*uuid.UUID{&target}} - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error removing requesting member") - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixKnock(brSender bridge.User, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix knock"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - log.Debug().Msg("Knocks aren't implemented yet :(") -} - -func (portal *Portal) HandleMatrixBan(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix ban"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - puppet := brGhost.(*Puppet) - groupChange := &signalmeow.GroupChange{AddBannedMembers: []*signalmeow.BannedMember{{ - ServiceID: libsignalgo.NewACIServiceID(puppet.SignalID), - Timestamp: uint64(time.Now().UnixMilli()), - }}} - switch prevMembership := evt.Unsigned.PrevContent.AsMember().Membership; prevMembership { - case event.MembershipJoin: - groupChange.DeleteMembers = []*uuid.UUID{&puppet.SignalID} - case event.MembershipKnock: - groupChange.DeleteRequestingMembers = []*uuid.UUID{&puppet.SignalID} - case event.MembershipInvite: - serviceID := libsignalgo.NewACIServiceID(puppet.SignalID) - groupChange.DeletePendingMembers = []*libsignalgo.ServiceID{&serviceID} - } - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error banning on Signal") - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixUnban(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix unban"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - puppet := brGhost.(*Puppet) - serviceID := libsignalgo.NewACIServiceID(puppet.SignalID) - groupChange := &signalmeow.GroupChange{DeleteBannedMembers: []*libsignalgo.ServiceID{&serviceID}} - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error unbanning on Signal") - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixPowerLevels(brSender bridge.User, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix power levels"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - if !sender.IsLoggedIn() { - log.Warn().Msg("Can't change power levels: user is not logged in") - return - } - evt.Content.ParseRaw(event.StatePowerLevels) - levels := evt.Content.AsPowerLevels() - var prevLevels *event.PowerLevelsEventContent - if evt.Unsigned.PrevContent != nil { - evt.Unsigned.PrevContent.ParseRaw(event.StatePowerLevels) - prevLevels = evt.Unsigned.PrevContent.AsPowerLevels() - } else { - prevLevels = &event.PowerLevelsEventContent{} - } - groupChange := &signalmeow.GroupChange{} - var role signalmeow.GroupMemberRole - for user, level := range levels.Users { - prevLevel := prevLevels.GetUserLevel(user) - if (level >= 50 && prevLevel < 50) || (level < 50 && prevLevel >= 50) { - puppet := portal.bridge.GetPuppetByMXID(user) - if puppet == nil { - log.Warn().Stringer("mxid", user).Msg("Couldn't get puppet for power level change") - continue - } - role = signalmeow.GroupMember_DEFAULT - if level >= 50 { - role = signalmeow.GroupMember_ADMINISTRATOR - } - groupChange.ModifyMemberRoles = append(groupChange.ModifyMemberRoles, &signalmeow.RoleMember{ - ACI: puppet.SignalID, - Role: role, - }) - } - } - if levels.EventsDefault >= 50 && prevLevels.EventsDefault < 50 { - announcementsOnly := true - groupChange.ModifyAnnouncementsOnly = &announcementsOnly - } else if levels.EventsDefault < 50 && prevLevels.EventsDefault >= 50 { - announcementsOnly := false - groupChange.ModifyAnnouncementsOnly = &announcementsOnly - } - if levels.StateDefault() >= 50 && prevLevels.StateDefault() < 50 { - attributesAccess := signalmeow.AccessControl_ADMINISTRATOR - groupChange.ModifyAttributesAccess = &attributesAccess - } else if levels.StateDefault() < 50 && prevLevels.StateDefault() >= 50 { - attributesAccess := signalmeow.AccessControl_MEMBER - groupChange.ModifyAttributesAccess = &attributesAccess - } - if levels.Invite() >= 50 && prevLevels.Invite() < 50 { - memberAccess := signalmeow.AccessControl_ADMINISTRATOR - groupChange.ModifyMemberAccess = &memberAccess - } else if levels.Invite() < 50 && prevLevels.Invite() >= 50 { - memberAccess := signalmeow.AccessControl_MEMBER - groupChange.ModifyMemberAccess = &memberAccess - } - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error changing group access control") - return - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixJoinRule(brSender bridge.User, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix join rule"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - if !sender.IsLoggedIn() { - log.Warn().Msg("Can't change join rule: user is not logged in") - return - } - evt.Content.ParseRaw(event.StateJoinRules) - joinRule := evt.Content.AsJoinRules().JoinRule - groupChange := &signalmeow.GroupChange{} - addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE - if joinRule == event.JoinRuleKnock { - addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR - } else if joinRule == event.JoinRulePublic { - addFromInviteLinkAccess = signalmeow.AccessControl_ANY - } - groupChange.ModifyAddFromInviteLinkAccess = &addFromInviteLinkAccess - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error updating group access control") - return - } - portal.Revision = revision - portal.Update(ctx) -} - -func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) { - log := portal.log.With(). - Str("action", "handle matrix meta"). - Stringer("event_id", evt.ID). - Str("event_type", evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - sender := brSender.(*User) - if !sender.IsLoggedIn() { - log.Warn().Msg("Can't change room info: user is not logged in") - return - } - - var err error - groupChange := &signalmeow.GroupChange{Revision: portal.Revision + 1} - var avatarPath *string - var avatarHash string - var avatarURL id.ContentURI - var avatarChanged bool - switch content := evt.Content.Parsed.(type) { - case *event.RoomNameEventContent: - if content.Name == portal.Name { - return - } - portal.Name = content.Name - groupChange.ModifyTitle = &content.Name - case *event.TopicEventContent: - if content.Topic == portal.Topic { - return - } - portal.Topic = content.Topic - groupChange.ModifyDescription = &content.Topic - case *event.RoomAvatarEventContent: - url := content.URL.ParseOrIgnore() - if url == portal.AvatarURL { - return - } - var data []byte - if !url.IsEmpty() { - data, err = portal.MainIntent().DownloadBytes(ctx, url) - if err != nil { - log.Err(err).Stringer("Failed to download updated avatar %s", url) - return - } - log.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{sender.MXID, url}) - } else { - log.Debug().Stringer("%s removed the group avatar", sender.MXID) - } - avatarPath, err = sender.Client.UploadGroupAvatar(ctx, data, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Failed to upload group avatar") - return - } - groupChange.ModifyAvatar = avatarPath - hash := sha256.Sum256(data) - avatarHash = hex.EncodeToString(hash[:]) - avatarChanged = true - avatarURL = url - } - revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - log.Err(err).Msg("Error updating group attributes") - return - } - if avatarChanged { - log.Debug().Msg("Successfully updated group avatar") - portal.AvatarSet = true - portal.AvatarPath = *avatarPath - portal.AvatarHash = avatarHash - portal.AvatarURL = avatarURL - portal.UpdateBridgeInfo(ctx) - } - portal.Revision = revision - portal.Update(ctx) - log.Info().Msg("finished updating group") -} - -func (portal *Portal) CleanupIfEmpty(ctx context.Context) { - log := portal.log.With(). - Str("action", "Clean up if empty"). - Logger() - users, err := portal.GetMatrixUsers(ctx) - if err != nil { - log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up") - return - } - - if len(users) == 0 { - log.Info().Msg("Room seems to be empty, cleaning up...") - portal.Delete() - portal.Cleanup(ctx, false) - } -} - -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) GetInviteLink(ctx context.Context, source *User) (string, error) { - info, err := source.Client.RetrieveGroupByID(ctx, portal.GroupID(), portal.Revision) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("source_user_id", source.MXID). - Msg("Failed to fetch group info") - return "", err - } - inviteLinkPassword, err := info.GetInviteLink() - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to get invite link") - } - return inviteLinkPassword, nil -} - -func (portal *Portal) ResetInviteLink(ctx context.Context, source *User) error { - inviteLinkPassword := signalmeow.GenerateInviteLinkPassword() - groupChange := &signalmeow.GroupChange{ModifyInviteLinkPassword: &inviteLinkPassword} - revision, err := source.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Error setting invite link password") - return err - } - portal.Revision = revision - return portal.Update(ctx) -} - -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 -} diff --git a/provisioning.go b/provisioning.go deleted file mode 100644 index 46b52d1..0000000 --- a/provisioning.go +++ /dev/null @@ -1,640 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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" - "strconv" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/gorilla/mux" - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" - "go.mau.fi/util/requestlog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/legacyprovision" - "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -type provisioningContextKey int - -const ( - provisioningUserKey provisioningContextKey = iota -) - -type provisioningHandle struct { - id int - context context.Context - cancel context.CancelFunc - channel <-chan signalmeow.ProvisioningResponse -} - -type ProvisioningAPI struct { - bridge *SignalBridge - log zerolog.Logger - provisioningHandles []*provisioningHandle - provisioningUsers map[string]int - provisioningMutexes map[string]*sync.Mutex -} - -func (prov *ProvisioningAPI) Init() { - prov.log.Debug().Str("prefix", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API") - prov.provisioningUsers = make(map[string]int) - prov.provisioningMutexes = make(map[string]*sync.Mutex) - 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("/v2/whoami", prov.WhoAmI).Methods(http.MethodGet) - r.HandleFunc("/v2/link/new", prov.LinkNew).Methods(http.MethodPost) - r.HandleFunc("/v2/link/wait/scan", prov.LinkWaitForScan).Methods(http.MethodPost) - r.HandleFunc("/v2/link/wait/account", prov.LinkWaitForAccount).Methods(http.MethodPost) - r.HandleFunc("/v2/logout", prov.Logout).Methods(http.MethodPost) - r.HandleFunc("/v2/resolve_identifier/{phonenum}", prov.ResolveIdentifier).Methods(http.MethodGet) - r.HandleFunc("/v2/pm/{phonenum}", prov.StartPM).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) - } -} - -func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret { - zerolog.Ctx(r.Context()).Warn().Msg("Authentication token does not match shared secret") - legacyprovision.JSONResponse(w, http.StatusForbidden, &mautrix.RespError{ - Err: "Authentication token does not match shared secret", - ErrCode: mautrix.MForbidden.ErrCode, - }) - return - } - userID := r.URL.Query().Get("user_id") - user := prov.bridge.GetUserByMXID(id.UserID(userID)) - h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), provisioningUserKey, user))) - }) -} - -func (prov *ProvisioningAPI) resolveIdentifier(ctx context.Context, user *User, inputPhone string) (int, *legacyprovision.ResolveIdentifierResponse, error) { - if user.Client == nil { - return http.StatusUnauthorized, nil, errors.New("not currently connected to Signal") - } - e164Number, err := strconv.ParseUint(numberCleaner.Replace(inputPhone), 10, 64) - if err != nil { - return http.StatusBadRequest, nil, fmt.Errorf("error parsing phone number: %w", err) - } - e164String := fmt.Sprintf("+%d", e164Number) - var aci, pni uuid.UUID - var recipient *types.Recipient - if recipient, err = user.Client.ContactByE164(ctx, e164String); err != nil { - return http.StatusInternalServerError, nil, fmt.Errorf("error looking up number in local contact list: %w", err) - } else if recipient != nil { - aci = recipient.ACI - pni = recipient.PNI - } else if resp, err := user.Client.LookupPhone(ctx, e164Number); err != nil { - return http.StatusInternalServerError, nil, fmt.Errorf("error looking up number on server: %w", err) - } else { - aci = resp[e164Number].ACI - pni = resp[e164Number].PNI - if aci == uuid.Nil && pni == uuid.Nil { - return http.StatusNotFound, nil, errors.New("user not found on Signal") - } - recipient, err = user.Client.Store.RecipientStore.UpdateRecipientE164(ctx, aci, pni, e164String) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save recipient entry after looking up phone") - } - aci, pni = recipient.ACI, recipient.PNI - } - zerolog.Ctx(ctx).Debug(). - Uint64("e164", e164Number). - Stringer("aci", aci). - Stringer("pni", pni). - Msg("Found DM target user") - - var targetServiceID libsignalgo.ServiceID - var otherUserInfo *legacyprovision.ResolveIdentifierResponseOtherUser - if aci != uuid.Nil { - targetServiceID = libsignalgo.NewACIServiceID(aci) - puppet := prov.bridge.GetPuppetBySignalID(aci) - otherUserInfo = &legacyprovision.ResolveIdentifierResponseOtherUser{ - MXID: puppet.MXID, - DisplayName: puppet.Name, - AvatarURL: puppet.AvatarURL, - } - } else { - targetServiceID = libsignalgo.NewPNIServiceID(pni) - // TODO fill other user displayname/avatar if there's a contact entry? - } - portal := user.GetPortalByChatID(targetServiceID.String()) - - return http.StatusOK, &legacyprovision.ResolveIdentifierResponse{ - RoomID: portal.MXID, - ChatID: legacyprovision.ResolveIdentifierResponseChatID{ - UUID: targetServiceID.String(), - Number: e164String, - }, - OtherUser: otherUserInfo, - }, nil -} - -func (prov *ProvisioningAPI) ResolveIdentifier(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - phoneNum := mux.Vars(r)["phonenum"] - - log := prov.log.With(). - Str("action", "resolve_identifier"). - Stringer("user_id", user.MXID). - Str("phone_num", phoneNum). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("resolving identifier") - - status, resp, err := prov.resolveIdentifier(ctx, user, phoneNum) - if err != nil { - errCode := "M_INTERNAL" - if status == http.StatusNotFound { - log.Debug().Msg("contact not found") - errCode = "M_NOT_FOUND" - } else { - log.Err(err).Msg("error looking up contact") - } - legacyprovision.JSONResponse(w, status, legacyprovision.Error{ - Success: false, - Error: err.Error(), - ErrCode: errCode, - }) - return - } - legacyprovision.JSONResponse(w, status, legacyprovision.Response{ - Success: true, - Status: "ok", - ResolveIdentifierResponse: resp, - }) -} - -func (prov *ProvisioningAPI) StartPM(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - phoneNum := mux.Vars(r)["phonenum"] - - log := prov.log.With(). - Str("action", "start_pm"). - Stringer("user_id", user.MXID). - Str("phone_num", phoneNum). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("starting private message") - - status, resp, err := prov.resolveIdentifier(ctx, user, phoneNum) - if err != nil { - errCode := "M_INTERNAL" - if status == http.StatusNotFound { - log.Debug().Msg("contact not found") - errCode = "M_NOT_FOUND" - } else { - log.Err(err).Msg("error looking up contact") - } - legacyprovision.JSONResponse(w, status, legacyprovision.Error{ - Success: false, - Error: err.Error(), - ErrCode: errCode, - }) - return - } - - portal := user.GetPortalByChatID(resp.ChatID.UUID) - if portal.MXID == "" { - if err := portal.CreateMatrixRoom(r.Context(), user, 0); err != nil { - log.Err(err).Msg("error looking up contact") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: "Error creating Matrix room", - ErrCode: "M_INTERNAL", - }) - return - } - resp.JustCreated = true - resp.RoomID = portal.MXID - } - if resp.JustCreated { - status = http.StatusCreated - } - - legacyprovision.JSONResponse(w, status, legacyprovision.Response{ - Success: true, - Status: "ok", - ResolveIdentifierResponse: resp, - }) -} - -func (prov *ProvisioningAPI) mutexForUser(user *User) *sync.Mutex { - if _, ok := prov.provisioningMutexes[user.MXID.String()]; !ok { - prov.provisioningMutexes[user.MXID.String()] = &sync.Mutex{} - } - return prov.provisioningMutexes[user.MXID.String()] -} - -func (prov *ProvisioningAPI) newOrExistingSession(user *User) (newSessionLoggedIn bool, handle *provisioningHandle, err error) { - prov.mutexForUser(user).Lock() - defer prov.mutexForUser(user).Unlock() - - if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok { - provisioningHandle := prov.provisioningHandles[existingSessionID] - return false, provisioningHandle, nil - } - - provChan, err := user.Login() - if err != nil { - return false, nil, fmt.Errorf("Error logging in: %w", err) - } - provisioningCtx, cancel := context.WithCancel(context.TODO()) - handle = &provisioningHandle{ - context: provisioningCtx, - cancel: cancel, - channel: provChan, - } - prov.provisioningHandles = append(prov.provisioningHandles, handle) - handle.id = len(prov.provisioningHandles) - 1 - prov.provisioningUsers[user.MXID.String()] = handle.id - return true, handle, nil -} - -func (prov *ProvisioningAPI) existingSession(user *User) (handle *provisioningHandle) { - prov.mutexForUser(user).Lock() - defer prov.mutexForUser(user).Unlock() - - if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok { - provisioningHandle := prov.provisioningHandles[existingSessionID] - return provisioningHandle - } - return nil -} - -func (prov *ProvisioningAPI) clearSession(ctx context.Context, user *User) { - log := zerolog.Ctx(ctx).With().Str("function", "clearSession").Logger() - prov.mutexForUser(user).Lock() - defer prov.mutexForUser(user).Unlock() - - if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok { - log.Debug().Int("existing_session_id", existingSessionID).Msg("clearing existing session") - if existingSessionID >= len(prov.provisioningHandles) { - log.Warn().Msg("session does not exist") - return - } - if prov.provisioningHandles[existingSessionID].cancel != nil { - prov.provisioningHandles[existingSessionID].cancel() - } - prov.provisioningHandles[existingSessionID] = nil - delete(prov.provisioningUsers, user.MXID.String()) - } else { - prov.log.Debug().Msg("no session found") - } -} - -func (prov *ProvisioningAPI) loginOrSendError(ctx context.Context, w http.ResponseWriter, user *User) (*provisioningHandle, error) { - newSessionLoggedIn, handle, err := prov.newOrExistingSession(user) - if err != nil { - return nil, err - } - if !newSessionLoggedIn { - zerolog.Ctx(ctx).Debug(). - Int("existing_provisioning_handle", handle.id). - Msg("user already has pending provisioning request, cancelling") - prov.clearSession(ctx, user) - _, handle, err = prov.newOrExistingSession(user) - if err != nil { - return nil, fmt.Errorf("error logging in after cancelling existing session: %w", err) - } - } - return handle, nil -} - -func (prov *ProvisioningAPI) checkSessionAndReturnHandle(ctx context.Context, w http.ResponseWriter, currentSession int) *provisioningHandle { - log := zerolog.Ctx(ctx).With().Str("function", "checkSessionAndReturnHandle").Logger() - user := ctx.Value(provisioningUserKey).(*User) - handle := prov.existingSession(user) - if handle == nil { - log.Warn().Msg("no session found") - legacyprovision.JSONResponse(w, http.StatusNotFound, legacyprovision.Error{ - Success: false, - Error: "No session found", - ErrCode: "M_NOT_FOUND", - }) - return nil - } - if handle.id != currentSession { - log.Warn(). - Int("handle_id", handle.id). - Int("current_session", currentSession). - Msg("session_id does not match user's session_id") - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "session_id does not match user's session_id", - ErrCode: "M_BAD_JSON", - }) - return nil - } - return handle -} - -func (prov *ProvisioningAPI) WhoAmI(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - log := prov.log.With(). - Str("action", "whoami"). - Stringer("user_id", user.MXID). - Logger() - log.Debug().Msg("getting whoami") - - data := legacyprovision.WhoAmIResponse{ - Permissions: int(user.PermissionLevel), - MXID: user.MXID.String(), - } - if user.IsLoggedIn() { - data.Signal = &legacyprovision.WhoAmIResponseSignal{ - Number: user.SignalUsername, - UUID: user.SignalID.String(), - Ok: user.Client.IsConnected(), - } - puppet := user.bridge.GetPuppetBySignalID(user.SignalID) - if puppet != nil { - data.Signal.Name = puppet.Name - } - } - legacyprovision.JSONResponse(w, http.StatusOK, data) -} - -func (prov *ProvisioningAPI) LinkNew(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - log := prov.log.With(). - Str("action", "link_new"). - Stringer("user_id", user.MXID). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("starting login") - - handle, err := prov.loginOrSendError(ctx, w, user) - if err != nil { - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: err.Error(), - ErrCode: "M_INTERNAL", - }) - return - } - - log = log.With().Int("session_id", handle.id).Logger() - log.Debug().Msg("waiting for provisioning response") - - select { - case resp := <-handle.channel: - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - log.Err(resp.Err).Msg("Error getting provisioning URL") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: resp.Err.Error(), - ErrCode: "M_INTERNAL", - }) - return - } - if resp.State != signalmeow.StateProvisioningURLReceived { - log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: fmt.Sprintf("Unexpected state %s", resp.State.String()), - ErrCode: "M_INTERNAL", - }) - return - } - - log.Debug().Str("provisioning_url", resp.ProvisioningURL).Msg("provisioning URL received") - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ - Success: true, - Status: "provisioning_url_received", - SessionID: fmt.Sprintf("%d", handle.id), - URI: resp.ProvisioningURL, - }) - case <-time.After(30 * time.Second): - log.Warn().Msg("Timeout waiting for provisioning response (new)") - legacyprovision.JSONResponse(w, http.StatusGatewayTimeout, legacyprovision.Error{ - Success: false, - Error: "Timeout waiting for provisioning response (new)", - ErrCode: "M_TIMEOUT", - }) - } -} - -func (prov *ProvisioningAPI) LinkWaitForScan(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - - var body legacyprovision.LinkWaitForScanRequest - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "Error decoding JSON body", - ErrCode: "M_BAD_JSON", - }) - return - } - sessionID, err := strconv.Atoi(body.SessionID) - if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "Error decoding session ID in JSON body", - ErrCode: "M_BAD_JSON", - }) - return - } - - log := prov.log.With(). - Str("action", "link_wait_for_scan"). - Stringer("user_id", user.MXID). - Str("session_id", body.SessionID). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("waiting for scan") - - handle := prov.checkSessionAndReturnHandle(ctx, w, sessionID) - if handle == nil { - return - } - - select { - case resp := <-handle.channel: - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - log.Err(resp.Err).Msg("Error waiting for scan") - // If context was cancelled be chill - if errors.Is(resp.Err, context.Canceled) { - log.Debug().Msg("Context cancelled waiting for scan") - return - } - // If we error waiting for the scan, treat it as a normal error not 5xx - // so that the client will retry quietly. Also, it's really not an internal - // error, sitting with a WS open waiting for a scan is inherently flaky. - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: resp.Err.Error(), - ErrCode: "M_INTERNAL", - }) - return - } - if resp.State != signalmeow.StateProvisioningDataReceived { - log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: fmt.Sprintf("Unexpected state %s", resp.State.String()), - ErrCode: "M_INTERNAL", - }) - return - } - log.Debug().Msg("provisioning data received") - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ - Success: true, - Status: "provisioning_data_received", - }) - - // Update user with SignalID - if resp.ProvisioningData.ACI != uuid.Nil { - user.saveSignalID(ctx, resp.ProvisioningData.ACI, resp.ProvisioningData.Number) - } - return - case <-time.After(45 * time.Second): - log.Warn().Msg("Timeout waiting for provisioning response (scan)") - // Using 400 here to match the old bridge - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "Timeout waiting for QR code scan", - ErrCode: "M_BAD_REQUEST", - }) - return - } -} - -func (prov *ProvisioningAPI) LinkWaitForAccount(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - - var body legacyprovision.LinkWaitForAccountRequest - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "Error decoding JSON body", - ErrCode: "M_BAD_JSON", - }) - return - } - sessionID, err := strconv.Atoi(body.SessionID) - if err != nil { - legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{ - Success: false, - Error: "Error decoding session ID in JSON body", - ErrCode: "M_BAD_JSON", - }) - return - } - deviceName := body.DeviceName - - log := prov.log.With(). - Str("action", "link_wait_for_account"). - Stringer("user_id", user.MXID). - Int("session_id", sessionID). - Str("device_name", deviceName). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("waiting for account") - - handle := prov.checkSessionAndReturnHandle(ctx, w, sessionID) - if handle == nil { - return - } - - select { - case resp := <-handle.channel: - if resp.Err != nil || resp.State == signalmeow.StateProvisioningError { - log.Err(resp.Err).Msg("Error waiting for account") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: resp.Err.Error(), - ErrCode: "M_INTERNAL", - }) - return - } - if resp.State != signalmeow.StateProvisioningPreKeysRegistered { - log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state") - legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{ - Success: false, - Error: fmt.Sprintf("Unexpected state %s", resp.State.String()), - ErrCode: "M_INTERNAL", - }) - return - } - - log.Debug().Msg("prekeys registered") - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ - Success: true, - Status: "prekeys_registered", - UUID: user.SignalID.String(), - Number: user.SignalUsername, - }) - - // Connect to Signal!! - user.Connect() - return - case <-time.After(30 * time.Second): - log.Warn().Msg("Timeout waiting for provisioning response (account)") - legacyprovision.JSONResponse(w, http.StatusGatewayTimeout, legacyprovision.Error{ - Success: false, - Error: "Timeout waiting for provisioning response (account)", - ErrCode: "M_TIMEOUT", - }) - return - } -} - -func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - log := prov.log.With(). - Str("action", "logout"). - Stringer("user_id", user.MXID). - Logger() - ctx := log.WithContext(r.Context()) - log.Debug().Msg("Logout called (but not logging out)") - - prov.clearSession(ctx, user) - - // For now do nothing - we need this API to return 200 to be compatible with - // the old Signal bridge, which needed a call to Logout before allowing LinkNew - // to be called, but we don't actually want to logout, we want to allow a reconnect. - legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{ - Success: true, - Status: "logged_out", - }) -} diff --git a/puppet.go b/puppet.go deleted file mode 100644 index 857312e..0000000 --- a/puppet.go +++ /dev/null @@ -1,411 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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/hex" - "fmt" - "net/http" - "regexp" - - "github.com/google/uuid" - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/pkg/signalmeow/types" -) - -func (br *SignalBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - signalID, ok := br.ParsePuppetMXID(mxid) - if !ok { - return nil - } - - return br.GetPuppetBySignalID(signalID) -} - -func (br *SignalBridge) GetPuppetBySignalIDString(id string) *Puppet { - parsed, err := uuid.Parse(id) - if err != nil { - return nil - } - return br.GetPuppetBySignalID(parsed) -} - -func (br *SignalBridge) GetPuppetBySignalID(id uuid.UUID) *Puppet { - if id == uuid.Nil { - br.ZLog.Warn().Msg("Trying to get puppet with empty signal_user_id") - return nil - } - - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - puppet, ok := br.puppets[id] - if !ok { - dbPuppet, err := br.DB.Puppet.GetBySignalID(context.TODO(), id) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get puppet from database") - return nil - } - return br.loadPuppet(context.TODO(), dbPuppet, &id) - } - return puppet -} - -func (br *SignalBridge) 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).Msg("Failed to get puppet from database") - return nil - } - return br.loadPuppet(context.TODO(), dbPuppet, nil) - } - return puppet -} - -func (br *SignalBridge) GetAllPuppetsWithCustomMXID() []*Puppet { - puppets, err := br.DB.Puppet.GetAllWithCustomMXID(context.TODO()) - if err != nil { - br.ZLog.Error().Err(err).Msg("Failed to get all puppets with custom MXID") - return nil - } - return br.dbPuppetsToPuppets(puppets) -} - -func (br *SignalBridge) FormatPuppetMXID(u uuid.UUID) id.UserID { - return id.NewUserID( - br.Config.Bridge.FormatUsername(u.String()), - br.Config.Homeserver.Domain, - ) -} - -func (br *SignalBridge) loadPuppet(ctx context.Context, dbPuppet *database.Puppet, u *uuid.UUID) *Puppet { - if dbPuppet == nil { - if u == nil { - return nil - } - dbPuppet = br.DB.Puppet.New() - dbPuppet.SignalID = *u - err := dbPuppet.Insert(ctx) - if err != nil { - br.ZLog.Error().Err(err).Stringer("signal_user_id", *u).Msg("Failed to insert new puppet") - return nil - } - } - - puppet := br.NewPuppet(dbPuppet) - br.puppets[puppet.SignalID] = puppet - if puppet.CustomMXID != "" { - br.puppetsByCustomMXID[puppet.CustomMXID] = puppet - } - return puppet -} - -func (br *SignalBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { - 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.SignalID] - if !ok { - puppet = br.loadPuppet(context.TODO(), dbPuppet, nil) - } - output[index] = puppet - } - return output -} - -func (br *SignalBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { - return &Puppet{ - Puppet: dbPuppet, - bridge: br, - log: br.ZLog.With().Stringer("signal_user_id", dbPuppet.SignalID).Logger(), - - MXID: br.FormatPuppetMXID(dbPuppet.SignalID), - } -} - -func (br *SignalBridge) ParsePuppetMXID(mxid id.UserID) (uuid.UUID, bool) { - if userIDRegex == nil { - pattern := fmt.Sprintf( - "^@%s:%s$", - // The "SignalID" portion of the MXID is a (lowercase) UUID - br.Config.Bridge.FormatUsername("([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"), - br.Config.Homeserver.Domain, - ) - br.ZLog.Debug().Str("pattern", pattern).Msg("Compiling userIDRegex") - - userIDRegex = regexp.MustCompile(pattern) - } - - match := userIDRegex.FindStringSubmatch(string(mxid)) - if len(match) == 2 { - parsed, err := uuid.Parse(match[1]) - if err != nil { - return uuid.Nil, false - } - return parsed, true - } - - return uuid.Nil, false -} - -type Puppet struct { - *database.Puppet - - bridge *SignalBridge - log zerolog.Logger - - MXID id.UserID - - customIntent *appservice.IntentAPI - customUser *User -} - -var userIDRegex *regexp.Regexp - -var ( - _ bridge.Ghost = (*Puppet)(nil) - _ bridge.GhostWithProfile = (*Puppet)(nil) -) - -func (puppet *Puppet) GetMXID() id.UserID { - return puppet.MXID -} - -func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { - return puppet.bridge.AS.Intent(puppet.MXID) -} - -func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { - if puppet == nil { - return nil - } - return puppet.customIntent -} - -func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { - if puppet != nil { - if puppet.customIntent == nil || portal.UserID().UUID == puppet.SignalID { - return puppet.DefaultIntent() - } - return puppet.customIntent - } - return nil -} - -func (puppet *Puppet) GetDisplayname() string { - return puppet.Name -} - -func (puppet *Puppet) GetAvatarURL() id.ContentURI { - return puppet.AvatarURL -} - -func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User, contactAvatar *types.ContactAvatar) { - log := zerolog.Ctx(ctx).With(). - Str("function", "Puppet.UpdateInfo"). - Stringer("signal_user_id", puppet.SignalID). - Logger() - ctx = log.WithContext(ctx) - var err error - log.Debug().Msg("Fetching contact info to update puppet") - info, err := source.Client.ContactByACI(ctx, puppet.SignalID) - if err != nil { - log.Err(err).Msg("Failed to fetch contact info") - return - } - if !puppet.bridge.Config.Bridge.UseOutdatedProfiles && puppet.ProfileFetchedAt.After(info.Profile.FetchedAt) { - log.Debug(). - Time("contact_profile_fetched_at", info.Profile.FetchedAt). - Time("puppet_profile_fetched_at", puppet.ProfileFetchedAt). - Msg("Ignoring outdated contact info") - return - } - if contactAvatar != nil { - info.ContactAvatar = *contactAvatar - } - - log.Trace().Msg("Updating puppet info") - - update := false - if puppet.ProfileFetchedAt.IsZero() && !info.Profile.FetchedAt.IsZero() { - update = true - } - puppet.ProfileFetchedAt = info.Profile.FetchedAt - if info.E164 != "" && puppet.Number != info.E164 { - puppet.Number = info.E164 - update = true - } - update = puppet.updateName(ctx, info) || update - update = puppet.updateAvatar(ctx, source, info) || update - if update { - puppet.ContactInfoSet = false - puppet.UpdateContactInfo(ctx) - err = puppet.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save puppet to database after updating") - } - go puppet.updatePortalMeta(ctx) - log.Debug().Msg("Puppet info updated") - } -} -func (puppet *Puppet) UpdateContactInfo(ctx context.Context) { - if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet { - return - } - - identifiers := []string{ - fmt.Sprintf("signal:%s", puppet.SignalID), - } - if puppet.Number != "" { - identifiers = append(identifiers, fmt.Sprintf("tel:%s", puppet.Number)) - } - contactInfo := map[string]any{ - "com.beeper.bridge.identifiers": identifiers, - "com.beeper.bridge.remote_id": puppet.SignalID.String(), - "com.beeper.bridge.service": "signal", - "com.beeper.bridge.network": "signal", - } - err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to store custom contact info in profile") - } else { - puppet.ContactInfoSet = true - } -} - -func (puppet *Puppet) updatePortalMeta(ctx context.Context) { - for _, portal := range puppet.bridge.FindPrivateChatPortalsWith(puppet.SignalID) { - // Get room create lock to prevent races between receiving contact info and room creation. - portal.roomCreateLock.Lock() - portal.UpdateDMInfo(ctx, false) - portal.roomCreateLock.Unlock() - } -} - -func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *types.Recipient) bool { - var avatarData []byte - var avatarContentType string - log := zerolog.Ctx(ctx) - if puppet.bridge.Config.Bridge.UseContactAvatars && info.ContactAvatar.Hash != "" { - if puppet.AvatarHash == info.ContactAvatar.Hash && puppet.AvatarSet { - return false - } - avatarData = info.ContactAvatar.Image - avatarContentType = info.ContactAvatar.ContentType - if avatarData == nil { - // TODO what to do? 🤔 - return false - } - puppet.AvatarSet = false - puppet.AvatarPath = "" - } else { - if puppet.AvatarPath == info.Profile.AvatarPath && puppet.AvatarSet { - return false - } - if info.Profile.AvatarPath == "" { - puppet.AvatarURL = id.ContentURI{} - puppet.AvatarPath = "" - puppet.AvatarHash = "" - puppet.AvatarSet = false - err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL) - if err != nil { - log.Err(err).Msg("Failed to remove user avatar") - return true - } - log.Debug().Msg("Avatar removed") - puppet.AvatarSet = true - return true - } - var err error - avatarData, err = source.Client.DownloadUserAvatar(ctx, info.Profile.AvatarPath, info.Profile.Key) - if err != nil { - log.Err(err). - Str("profile_avatar_path", info.Profile.AvatarPath). - Msg("Failed to download new user avatar") - return true - } - avatarContentType = http.DetectContentType(avatarData) - } - hash := sha256.Sum256(avatarData) - newHash := hex.EncodeToString(hash[:]) - if puppet.AvatarHash == newHash && puppet.AvatarSet { - log.Debug(). - Str("avatar_hash", newHash). - Str("new_avatar_path", puppet.AvatarPath). - Msg("Avatar path changed, but hash didn't") - // Path changed, but actual avatar didn't - return true - } - puppet.AvatarPath = info.Profile.AvatarPath - puppet.AvatarHash = newHash - puppet.AvatarSet = false - puppet.AvatarURL = id.ContentURI{} - resp, err := puppet.DefaultIntent().UploadBytes(ctx, avatarData, avatarContentType) - if err != nil { - log.Err(err). - Str("avatar_hash", puppet.AvatarHash). - Msg("Failed to upload new user avatar") - return true - } - puppet.AvatarURL = resp.ContentURI - err = puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL) - if err != nil { - log.Err(err).Msg("Failed to update user avatar") - return true - } - log.Debug(). - Str("avatar_hash", newHash). - Stringer("avatar_mxc", resp.ContentURI). - Msg("Avatar updated successfully") - puppet.AvatarSet = true - return true -} - -func (puppet *Puppet) updateName(ctx context.Context, contact *types.Recipient) bool { - // TODO set name quality - newName := puppet.bridge.Config.Bridge.FormatDisplayname(contact) - if puppet.NameSet && puppet.Name == newName { - return false - } - puppet.Name = newName - puppet.NameSet = false - err := puppet.DefaultIntent().SetDisplayName(ctx, newName) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update user displayname") - } else { - puppet.NameSet = true - } - return true -} diff --git a/user.go b/user.go deleted file mode 100644 index dc16f98..0000000 --- a/user.go +++ /dev/null @@ -1,1020 +0,0 @@ -// mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber -// -// 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" - "net/http" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/rs/zerolog" - "golang.org/x/exp/maps" - "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/id" - - "go.mau.fi/mautrix-signal/database" - "go.mau.fi/mautrix-signal/pkg/libsignalgo" - "go.mau.fi/mautrix-signal/pkg/signalmeow" - "go.mau.fi/mautrix-signal/pkg/signalmeow/events" - signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" -) - -var ( - ErrNotConnected = errors.New("not connected") - ErrNotLoggedIn = errors.New("not logged in") -) - -func (br *SignalBridge) GetUserByMXID(userID id.UserID) *User { - return br.maybeGetUserByMXID(userID, &userID) -} - -func (br *SignalBridge) GetUserByMXIDIfExists(userID id.UserID) *User { - return br.maybeGetUserByMXID(userID, nil) -} - -func (br *SignalBridge) maybeGetUserByMXID(userID id.UserID, userIDPtr *id.UserID) *User { - if userID == br.Bot.UserID || br.IsGhost(userID) { - return nil - } - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByMXID[userID] - if !ok { - dbUser, err := br.DB.User.GetByMXID(context.TODO(), userID) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get user from database") - return nil - } - return br.loadUser(context.TODO(), dbUser, userIDPtr) - } - return user -} - -func (br *SignalBridge) GetUserBySignalID(id uuid.UUID) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - return br.unlockedGetUserBySignalID(id) -} - -func (br *SignalBridge) unlockedGetUserBySignalID(id uuid.UUID) *User { - user, ok := br.usersBySignalID[id] - if !ok { - dbUser, err := br.DB.User.GetBySignalID(context.TODO(), id) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get user from database") - return nil - } - return br.loadUser(context.TODO(), dbUser, nil) - } - return user -} - -func (br *SignalBridge) GetAllLoggedInUsers() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - dbUsers, err := br.DB.User.GetAllLoggedIn(context.TODO()) - if err != nil { - br.ZLog.Err(err).Msg("Error getting all logged in users") - return nil - } - users := make([]*User, len(dbUsers)) - - for idx, dbUser := range dbUsers { - user, ok := br.usersByMXID[dbUser.MXID] - if !ok { - user = br.loadUser(context.TODO(), dbUser, nil) - } - users[idx] = user - } - return users -} - -func (br *SignalBridge) loadUser(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.Err(err).Msg("Error creating user %s") - return nil - } - } - - user := br.NewUser(dbUser) - br.usersByMXID[user.MXID] = user - if user.SignalID != uuid.Nil { - br.usersBySignalID[user.SignalID] = user - } - if user.ManagementRoom != "" { - br.managementRoomsLock.Lock() - br.managementRooms[user.ManagementRoom] = user - br.managementRoomsLock.Unlock() - } - return user -} - -func (br *SignalBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.ZLog.With().Stringer("user_id", dbUser.MXID).Logger(), - - PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID), - } - user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin - user.BridgeState = br.NewBridgeStateQueue(user) - return user -} - -type User struct { - *database.User - - sync.Mutex - - bridge *SignalBridge - log zerolog.Logger - - Admin bool - PermissionLevel bridgeconfig.PermissionLevel - - Client *signalmeow.Client - - BridgeState *bridge.BridgeStateQueue - - spaceMembershipChecked bool - spaceCreateLock sync.Mutex -} - -var ( - _ bridge.User = (*User)(nil) - _ status.BridgeStateFiller = (*User)(nil) -) - -func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { - return user.PermissionLevel -} - -func (user *User) IsLoggedIn() bool { - user.Lock() - defer user.Unlock() - - return user.Client != nil && user.Client.IsLoggedIn() -} - -func (user *User) GetManagementRoomID() id.RoomID { - return user.ManagementRoom -} - -func (user *User) SetManagementRoom(roomID id.RoomID) { - user.bridge.managementRoomsLock.Lock() - defer user.bridge.managementRoomsLock.Unlock() - - existing, ok := user.bridge.managementRooms[roomID] - if ok { - existing.ManagementRoom = "" - err := existing.Update(context.TODO()) - if err != nil { - existing.log.Err(err).Msg("Failed to update user when removing management room") - } - } - - user.ManagementRoom = roomID - user.bridge.managementRooms[user.ManagementRoom] = user - err := user.Update(context.TODO()) - if err != nil { - user.log.Error().Err(err).Msg("Error setting management room") - } -} - -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 { - p := user.bridge.GetPuppetBySignalID(user.SignalID) - if p == nil { - return nil - } - return p -} - -func (user *User) ensureInvited(ctx context.Context, intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { - log := user.log.With().Str("action", "ensure_invited").Stringer("room_id", roomID).Logger() - if user.bridge.StateStore.IsMembership(ctx, roomID, user.MXID, event.MembershipJoin) { - ok = true - return - } - 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 { - log.Warn().Err(err).Msg("Failed to update membership in state store") - } - ok = true - return - } else if err != nil { - log.Warn().Err(err).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 { - log.Warn().Err(err).Msg("Failed to auto-join custom puppet") - 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: "Signal", - Topic: "Your Signal 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.log.Err(err).Msg("Failed to auto-create space room") - } else { - user.SpaceRoom = resp.RoomID - err = user.Update(context.TODO()) - if err != nil { - user.log.Err(err).Msg("Failed to save user in database after creating space room") - } - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - } else if !user.spaceMembershipChecked { - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - user.spaceMembershipChecked = true - - return user.SpaceRoom -} - -func (user *User) syncChatDoublePuppetDetails(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 - } - - // TODO: Get chat setting from Signal and sync them here - //if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate { - // chat, err := user.Client.Store.ChatSettings.GetChatSettings(portal.Key().ChatID) - // if err != nil { - // user.log.Warn().Err(err).Msgf("Failed to get settings of %s", portal.Key().ChatID) - // return - // } - // intent := doublePuppet.CustomIntent() - // if portal.Key.JID == types.StatusBroadcastJID && justCreated { - // if user.bridge.Config.Bridge.MuteStatusBroadcast { - // user.updateChatMute(intent, portal, time.Now().Add(365*24*time.Hour)) - // } - // if len(user.bridge.Config.Bridge.StatusBroadcastTag) > 0 { - // user.updateChatTag(intent, portal, user.bridge.Config.Bridge.StatusBroadcastTag, true) - // } - // return - // } else if !chat.Found { - // return - // } - // user.updateChatMute(intent, portal, chat.MutedUntil) - // user.updateChatTag(intent, portal, user.bridge.Config.Bridge.ArchiveTag, chat.Archived) - // user.updateChatTag(intent, portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned) - //} -} - -func (user *User) GetMXID() id.UserID { - return user.MXID -} -func (user *User) GetRemoteID() string { - return user.SignalID.String() -} -func (user *User) GetRemoteName() string { - return user.SignalUsername -} - -func (user *User) startupTryConnect(retryCount int) { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - - // Make sure user has the Signal device populated - user.populateSignalDevice() - - user.log.Debug().Msg("Connecting to Signal") - ctx := user.log.WithContext(context.Background()) - statusChan, err := user.Client.StartReceiveLoops(ctx) - - if err != nil { - user.log.Error().Err(err).Msg("Error connecting on startup") - if errors.Is(err, ErrNotLoggedIn) { - user.log.Warn().Msg("Not logged in, clearing Signal device keys") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"}) - user.clearKeysAndDisconnect() - } else if retryCount < 6 { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()}) - retryInSeconds := 2 << retryCount - user.log.Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection") - time.Sleep(time.Duration(retryInSeconds) * time.Second) - user.startupTryConnect(retryCount + 1) - } else { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "unknown-websocket-error", Message: err.Error()}) - } - } - - if statusChan == nil { - user.log.Error().Msg("statusChan is nil after Connect") - return - } - // After Connect returns, all bridge states are triggered by events on the statusChan - go func() { - var peekedConnectionStatus signalmeow.SignalConnectionStatus - for { - var connectionStatus signalmeow.SignalConnectionStatus - if peekedConnectionStatus.Event != signalmeow.SignalConnectionEventNone { - user.log.Debug(). - Stringer("peeked_connection_status_event", peekedConnectionStatus.Event). - Msg("Using peeked connectionStatus event") - connectionStatus = peekedConnectionStatus - peekedConnectionStatus = signalmeow.SignalConnectionStatus{} - } else { - var ok bool - connectionStatus, ok = <-statusChan - if !ok { - user.log.Debug().Msg("statusChan channel closed") - return - } - } - - err := connectionStatus.Err - switch connectionStatus.Event { - case signalmeow.SignalConnectionEventConnected: - user.log.Debug().Msg("Sending Connected BridgeState") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - user.bridge.Metrics.TrackConnectionState(user.SignalID, true) - user.bridge.Metrics.TrackLoginState(user.SignalID, true) - - case signalmeow.SignalConnectionEventDisconnected: - user.log.Debug().Msg("Received SignalConnectionEventDisconnected") - - // Debounce: wait 7s before sending TransientDisconnect, in case we get a reconnect - // We should wait until the next message comes in, or 7 seconds has passed. - // - If a disconnected event comes in, just loop again, unless it's been more than 7 seconds. - // - If a non-disconnected event comes in, store it in peekedConnectionStatus, - // break out of this loop and go back to the top of the goroutine to handle it in the switch. - // - If 7 seconds passes without any non-disconnect messages, send the TransientDisconnect. - // (Why 7 seconds? It was 5 at first, but websockets min retry is 5 seconds, - // so it would send TransientDisconnect right before reconnecting. 7 seems to work well.) - debounceTimer := time.NewTimer(7 * time.Second) - PeekLoop: - for { - var ok bool - select { - case peekedConnectionStatus, ok = <-statusChan: - // Handle channel closing - if !ok { - user.log.Debug().Msg("connectionStatus channel closed") - return - } - // If it's another Disconnected event, just keep looping - if peekedConnectionStatus.Event == signalmeow.SignalConnectionEventDisconnected { - peekedConnectionStatus = signalmeow.SignalConnectionStatus{} - continue - } - // If it's a non-disconnect event, break out of the PeekLoop and handle it in the switch - break PeekLoop - case <-debounceTimer.C: - // Time is up, so break out of the loop and send the TransientDisconnect - break PeekLoop - } - } - // We're out of the PeekLoop, so either we got a non-disconnect event, or it's been 7 seconds (or both). - // We want to send TransientDisconnect if it's been 7 seconds, but not if the latest event was something - // other than Disconnected - if !debounceTimer.Stop() { // If the timer has already expired - // Send TransientDisconnect only if the latest event is a disconnect or no event - // (peekedConnectionStatus could be something else if the timer and the event race) - if peekedConnectionStatus.Event == signalmeow.SignalConnectionEventDisconnected || - peekedConnectionStatus.Event == signalmeow.SignalConnectionEventNone { - user.log.Debug().Msg("Sending TransientDisconnect BridgeState") - if err == nil { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect}) - } else { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()}) - } - user.bridge.Metrics.TrackConnectionState(user.SignalID, false) - } - } - - case signalmeow.SignalConnectionEventLoggedOut: - user.log.Debug().Msg("Sending BadCredentials BridgeState") - if err == nil { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"}) - } else { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: err.Error()}) - } - user.bridge.Metrics.TrackConnectionState(user.SignalID, false) - user.bridge.Metrics.TrackLoginState(user.SignalID, false) - user.clearKeysAndDisconnect() - if managementRoom := user.GetManagementRoomID(); managementRoom != "" { - _, _ = user.bridge.Bot.SendText(ctx, managementRoom, "You've been logged out of Signal") - } - - case signalmeow.SignalConnectionEventError: - user.log.Debug().Msg("Sending UnknownError BridgeState") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "unknown-websocket-error", Message: err.Error()}) - user.bridge.Metrics.TrackConnectionState(user.SignalID, false) - - case signalmeow.SignalConnectionCleanShutdown: - if user.Client.IsLoggedIn() { - user.log.Debug().Msg("Clean Shutdown - sending no BridgeState") - } else { - user.log.Debug().Msg("Clean Shutdown, but logged out - Sending BadCredentials BridgeState") - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"}) - } - user.bridge.Metrics.TrackConnectionState(user.SignalID, false) - } - } - }() -} - -func (user *User) clearKeysAndDisconnect() { - // We need to clear out keys associated with the Signal device that no longer has valid credentials - user.log.Debug().Msg("Clearing out Signal device keys") - err := user.Client.ClearKeysAndDisconnect(context.TODO()) - if err != nil { - user.log.Err(err).Msg("Error clearing device keys") - } -} - -func (br *SignalBridge) StartUsers() { - br.ZLog.Debug().Msg("Starting users") - - usersWithToken := br.GetAllLoggedInUsers() - for _, u := range usersWithToken { - device := u.populateSignalDevice() - if device == nil || !device.IsLoggedIn() { - br.ZLog.Warn().Stringer("user_id", u.MXID).Msg("No device found for user, skipping Connect and sending BadCredentials BridgeState") - u.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"}) - continue - } - go u.Connect() - } - if len(usersWithToken) == 0 { - br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) - } - - br.ZLog.Debug().Msg("Starting custom puppets") - for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() { - go func(puppet *Puppet) { - br.ZLog.Debug().Stringer("user_id", puppet.CustomMXID).Msg("Starting custom puppet") - - if err := puppet.StartCustomMXID(true); err != nil { - puppet.log.Error().Err(err).Msg("Failed to start custom puppet") - } - }(customPuppet) - } -} - -func (user *User) Login() (<-chan signalmeow.ProvisioningResponse, error) { - user.Lock() - defer user.Unlock() - - provChan := signalmeow.PerformProvisioning(context.TODO(), user.bridge.MeowStore, user.bridge.Config.Signal.DeviceName) - - return provChan, nil -} - -func (user *User) Connect() { - user.startupTryConnect(0) -} - -func (user *User) saveSignalID(ctx context.Context, id uuid.UUID, number string) { - user.bridge.usersLock.Lock() - defer user.bridge.usersLock.Unlock() - if user.SignalID == id && user.SignalUsername == number { - return - } - if user.SignalID != id { - existingUser := user.bridge.unlockedGetUserBySignalID(id) - if existingUser != nil { - // TODO this doesn't clear the signal store properly - // the store also only has the uuid as primary key, even though it should have uuid + device id - zerolog.Ctx(ctx).Warn(). - Stringer("previous_user", existingUser.MXID). - Stringer("signal_uuid", id). - Msg("Another user is already logged in with same UUID, logging out previous user") - existingUser.bridge.Metrics.TrackLoginState(user.SignalID, false) - _ = existingUser.Disconnect() - existingUser.SignalID = uuid.Nil - existingUser.SignalUsername = "" - err := existingUser.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to clear previous user's signal UUID") - } - } - } - user.SignalID = id - user.SignalUsername = number - user.bridge.usersBySignalID[id] = user - err := user.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save user's signal UUID") - } -} - -func (user *User) populateSignalDevice() *signalmeow.Client { - user.Lock() - defer user.Unlock() - log := user.log.With(). - Str("action", "populate signal device"). - Stringer("signal_id", user.SignalID). - Logger() - - if user.SignalID == uuid.Nil { - return nil - } - // TODO clear client on logout properly so that populating can skip creating if it already exists - /*else if user.Client != nil { - return user.Client - }*/ - - device, err := user.bridge.MeowStore.DeviceByACI(context.TODO(), user.SignalID) - if err != nil { - log.Err(err).Msg("Failed to get device from database") - return nil - } else if device == nil { - log.Err(ErrNotLoggedIn).Msg("No device found for user") - return nil - } - - user.Client = &signalmeow.Client{ - Store: device, - EventHandler: user.eventHandler, - } - go user.tryAutomaticDoublePuppeting() - return user.Client -} - -func (user *User) handleReceipt(evt *events.Receipt) { - log := user.log.With(). - Str("action", "handle receipt"). - Stringer("receipt_type", evt.Content.GetType()). - Stringer("sender_uuid", evt.Sender). - Logger() - ctx := log.WithContext(context.TODO()) - messages, err := user.bridge.DB.Message.GetManyBySignalID(ctx, user.SignalID, evt.Content.GetTimestamp(), user.SignalID, false) - if err != nil { - log.Err(err).Msg("Failed to get receipt target messages from database") - return - } - sender := user.bridge.GetPuppetBySignalID(evt.Sender) - missingMessageIDMap := make(map[uint64]struct{}, len(evt.Content.GetTimestamp())) - for _, msg := range evt.Content.GetTimestamp() { - missingMessageIDMap[msg] = struct{}{} - } - foundMessageIDs := make([]uint64, len(messages)) - switch evt.Content.GetType() { - case signalpb.ReceiptMessage_READ: - messageMap := make(map[string]*database.Message) - for i, msg := range messages { - foundMessageIDs[i] = msg.Timestamp - delete(missingMessageIDMap, msg.Timestamp) - // The database returns messages from newest to oldest, so only include the first message per chat - _, ok := messageMap[msg.SignalChatID] - if !ok { - messageMap[msg.SignalChatID] = msg - } - } - log.Debug(). - Uints64("found_message_ids", foundMessageIDs). - Uints64("not_found_message_ids", maps.Keys(missingMessageIDMap)). - Int("chat_count", len(messageMap)). - Msg("Received read receipt") - for _, msg := range messageMap { - portal := user.GetPortalByChatID(msg.SignalChatID) - if portal == nil { - continue - } - err = portal.SendReadReceipt(ctx, sender, msg) - if err != nil { - log.Err(err).Msg("Failed to send read receipt") - } - } - case signalpb.ReceiptMessage_DELIVERY: - messageMap := make(map[string][]*database.Message) - for i, msg := range messages { - foundMessageIDs[i] = msg.Timestamp - delete(missingMessageIDMap, msg.Timestamp) - messageMap[msg.SignalChatID] = append(messageMap[msg.SignalChatID], msg) - } - log.Debug(). - Uints64("found_message_ids", foundMessageIDs). - Uints64("not_found_message_ids", maps.Keys(missingMessageIDMap)). - Int("chat_count", len(messageMap)). - Msg("Received delivery receipt") - for _, msgs := range messageMap { - portal := user.GetPortalByChatID(msgs[0].SignalChatID) - if portal == nil { - continue - } - // There should always only be 1 part, but use the last part to be safe - portal.MarkDelivered(ctx, msgs[len(msgs)-1]) - } - } -} - -func (user *User) handleReadSelf(evt *events.ReadSelf) { - ctx := context.TODO() - messagesByChat := map[string]*database.Message{} - for _, part := range evt.Messages { - log := user.log.With(). - Str("action", "handle read self"). - Str("sender_uuid", part.GetSenderAci()). - Uint64("msg_timestamp", part.GetTimestamp()). - Logger() - ctx := log.WithContext(context.TODO()) - if senderUUID, err := uuid.Parse(part.GetSenderAci()); err != nil { - log.Err(err).Msg("Failed to parse sender UUID") - } else if msg, err := user.bridge.DB.Message.GetLastPartBySignalIDWithUnknownReceiver(ctx, senderUUID, part.GetTimestamp(), user.SignalID); err != nil { - log.Err(err).Msg("Failed to get message from database") - } else if msg == nil { - log.Warn().Msg("Message not found in database") - } else if existingMsg, ok := messagesByChat[msg.SignalChatID]; ok && existingMsg.Timestamp > msg.Timestamp { - log.Trace(). - Str("chat_id", msg.SignalChatID). - Uint64("newer_msg", existingMsg.Timestamp). - Msg("Receipt event contains a newer message, skipping this one") - } else { - log.Trace().Str("chat_id", msg.SignalChatID).Msg("Received own read receipt") - messagesByChat[msg.SignalChatID] = msg - } - } - puppet := user.bridge.GetPuppetBySignalID(user.SignalID) - for _, msg := range messagesByChat { - portal := user.GetPortalByChatID(msg.SignalChatID) - if portal == nil { - continue - } - user.log.Debug(). - Str("action", "handle read self"). - Str("chat_id", msg.SignalChatID). - Uint64("msg_timestamp", msg.Timestamp). - Stringer("msg_mxid", msg.MXID). - Msg("Bridging own read receipt") - portal.ScheduleDisappearing() - user.SetLastReadTS(ctx, portal.PortalKey, msg.Timestamp) - err := portal.SendReadReceipt(ctx, puppet, msg) - if err != nil { - user.log.Err(err).Stringer("mxid", msg.MXID).Msg("Failed to send read receipt") - } - } -} - -func (user *User) handleContactList(evt *events.ContactList) { - ctx := user.log.With().Str("action", "handle contact list").Logger().WithContext(context.TODO()) - for _, contact := range evt.Contacts { - if contact.ACI == uuid.Nil { - continue - } - puppet := user.bridge.GetPuppetBySignalID(contact.ACI) - if puppet == nil { - continue - } - puppet.UpdateInfo(ctx, user, &contact.ContactAvatar) - } -} - -func (user *User) handleACIFound(evt *events.ACIFound) { - log := user.log.With(). - Str("action", "handle aci found"). - Stringer("aci", evt.ACI.UUID). - Stringer("pni", evt.PNI.UUID). - Logger() - log.Debug().Msg("Handling ACI found event") - defer func() { - log.Debug().Msg("Finished handling ACI found event") - }() - ctx := log.WithContext(context.TODO()) - user.bridge.portalsLock.Lock() - defer user.bridge.portalsLock.Unlock() - pniPortal := user.bridge.unlockedGetPortalByChatID(database.PortalKey{ - ChatID: evt.PNI.String(), - Receiver: user.SignalID, - }, false) - if pniPortal == nil { - log.Debug().Msg("PNI portal doesn't exist, ignoring event") - return - } - pniPortal.roomCreateLock.Lock() - defer pniPortal.roomCreateLock.Unlock() - if pniPortal.MXID == "" { - log.Info().Msg("PNI portal doesn't have Matrix room, deleting row") - pniPortal.unlockedDelete() - return - } - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("pni_portal_mxid", pniPortal.MXID) - }) - aciPortal := user.bridge.unlockedGetPortalByChatID(database.PortalKey{ - ChatID: evt.ACI.String(), - Receiver: user.SignalID, - }, false) - if aciPortal == nil { - log.Info().Msg("ACI portal doesn't exist, re-ID'ing PNI portal") - err := pniPortal.unlockedReID(ctx, evt.ACI.String()) - if err != nil { - log.Err(err).Msg("Failed to re-ID PNI portal") - } else { - go pniPortal.PostReIDUpdate(ctx, user) - } - return - } - aciPortal.roomCreateLock.Lock() - defer aciPortal.roomCreateLock.Unlock() - if aciPortal.MXID == "" { - log.Info().Msg("ACI portal row exists, but doesn't have a Matrix room. Deleting ACI portal row and re-ID'ing PNI portal") - aciPortal.unlockedDelete() - err := pniPortal.unlockedReID(ctx, evt.ACI.String()) - if err != nil { - log.Err(err).Msg("Failed to re-ID PNI portal") - } else { - go pniPortal.PostReIDUpdate(ctx, user) - } - } else { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Stringer("aci_portal_mxid", aciPortal.MXID) - }) - log.Info().Msg("Both ACI and PNI portal have Matrix room, tombstoning PNI portal") - pniPortal.unlockedDelete() - go func() { - _, err := pniPortal.MainIntent().SendStateEvent(ctx, pniPortal.MXID, event.StateTombstone, "", &event.TombstoneEventContent{ - Body: fmt.Sprintf("This room has been merged"), - ReplacementRoom: aciPortal.MXID, - }) - if err != nil { - log.Err(err).Msg("Failed to send tombstone to PNI portal") - } - pniPortal.Cleanup(ctx, err == nil) - }() - } -} - -func (portal *Portal) unlockedReID(ctx context.Context, newID string) error { - err := portal.Portal.ReID(ctx, newID) - if err != nil { - return err - } - delete(portal.bridge.portalsByID, portal.PortalKey) - portal.PortalKey.ChatID = newID - portal.bridge.portalsByID[portal.PortalKey] = portal - err = portal.MainIntent().EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{IgnoreCache: true}) - if err != nil { - return fmt.Errorf("failed to ensure ghost is joined to portal: %w", err) - } - return nil -} - -func (user *User) eventHandler(rawEvt events.SignalEvent) { - switch evt := rawEvt.(type) { - case *events.ChatEvent: - portal := user.GetPortalByChatID(evt.Info.ChatID) - if portal != nil { - portal.signalMessages <- portalSignalMessage{user: user, evt: evt} - } else { - user.log.Warn().Str("chat_id", evt.Info.ChatID).Msg("Couldn't get portal, dropping message") - } - case *events.DecryptionError: - portal := user.GetPortalByChatID(evt.Sender.String()) - if portal == nil { - user.log.Warn().Stringer("chat_id", evt.Sender).Msg("Couldn't get portal for decryption error") - return - } - content := &event.MessageEventContent{MsgType: event.MsgNotice} - name := user.bridge.GetPuppetBySignalID(evt.Sender).Name - if name == "" { - name = "This user" - } - content.Body = fmt.Sprintf("%s sent a message that couldn't be decrypted. It may have been in this chat or a group chat. Please check your Signal app", name) - portal.sendMainIntentMessage(context.TODO(), content) - case *events.Receipt: - user.handleReceipt(evt) - case *events.ReadSelf: - user.handleReadSelf(evt) - case *events.Call: - portal := user.GetPortalByChatID(evt.Info.ChatID) - content := &event.MessageEventContent{MsgType: event.MsgNotice} - if evt.IsRinging { - content.Body = "Incoming call" - if portal.IsPrivateChat() { - content.MsgType = event.MsgText - } - } else { - content.Body = "Call ended" - } - portal.sendMainIntentMessage(context.TODO(), content) - case *events.ContactList: - user.handleContactList(evt) - case *events.ACIFound: - user.handleACIFound(evt) - default: - user.log.Warn().Type("event_type", evt).Msg("Unrecognized event type from signalmeow") - } -} - -func (user *User) GetPortalByChatID(signalID string) *Portal { - return user.bridge.GetPortalByChatID(database.PortalKey{ - ChatID: signalID, - Receiver: user.SignalID, - }) -} - -func (user *User) GetPortalByChatIDIfExists(signalID string) *Portal { - return user.bridge.GetPortalByChatIDIfExists(database.PortalKey{ - ChatID: signalID, - Receiver: user.SignalID, - }) -} - -func (user *User) disconnectNoLock() (*signalmeow.Client, error) { - if user.Client == nil { - return nil, ErrNotConnected - } - - disconnectedDevice := user.Client - err := user.Client.StopReceiveLoops() - user.Client = nil - return disconnectedDevice, err -} - -func (user *User) Disconnect() error { - user.Lock() - defer user.Unlock() - user.log.Info().Msg("Disconnecting session manually") - _, err := user.disconnectNoLock() - return err -} - -func (user *User) Logout() error { - user.Lock() - defer user.Unlock() - user.log.Info().Msg("Logging out of session") - loggedOutDevice, err := user.disconnectNoLock() - user.bridge.MeowStore.DeleteDevice(context.TODO(), &loggedOutDevice.Store.DeviceData) - user.bridge.GetPuppetByCustomMXID(user.MXID).ClearCustomMXID() - user.bridge.Metrics.TrackLoginState(user.SignalID, false) - return err -} - -func (user *User) UpdateDirectChats(ctx context.Context, chats map[id.UserID][]id.RoomID) { - if !user.bridge.Config.Bridge.SyncDirectChatList { - return - } - - puppet := user.bridge.GetPuppetByMXID(user.MXID) - if puppet == nil { - return - } - - intent := puppet.CustomIntent() - if intent == nil { - return - } - - method := http.MethodPatch - if chats == nil { - chats = user.getDirectChats() - method = http.MethodPut - } - - user.log.Debug().Msg("Updating m.direct list on homeserver") - - var err error - if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux { - urlPath := intent.BuildURL(mautrix.ClientURLPath{"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 := map[id.UserID][]id.RoomID{} - - err = intent.GetAccountData(ctx, event.AccountDataDirectChats.Type, &existingChats) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to get m.direct event 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.log.Warn().Err(err).Msg("Failed to update m.direct event") - } -} - -func (user *User) getDirectChats() map[id.UserID][]id.RoomID { - chats := map[id.UserID][]id.RoomID{} - - privateChats, err := user.bridge.DB.Portal.FindPrivateChatsOf(context.TODO(), user.SignalID) - if err != nil { - user.log.Err(err).Msg("Failed to get private chats") - return chats - } - for _, portal := range privateChats { - portalUserID := portal.UserID() - if portal.MXID != "" && portalUserID.Type == libsignalgo.ServiceIDTypeACI { - puppetMXID := user.bridge.FormatPuppetMXID(portalUserID.UUID) - - chats[puppetMXID] = []id.RoomID{portal.MXID} - } - } - - return chats -}