From 63284481d7e983c75ea5295063179f8b50e57b45 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 2 Jul 2023 17:21:55 +0300 Subject: [PATCH] Add initial compiling version of bridge --- .gitignore | 1 + Dockerfile | 1 - bridgestate.go | 94 +++ commands.go | 321 ++++++++ config/bridge.go | 159 ++++ config/config.go | 42 + config/upgrade.go | 108 +++ custompuppet.go | 168 ++++ database/database.go | 77 ++ database/message.go | 104 +++ database/portal.go | 138 ++++ database/puppet.go | 92 +++ database/upgrades/00-latest-revision.sql | 64 ++ database/upgrades/upgrades.go | 32 + database/user.go | 144 ++++ docker-run.sh | 36 + example-config.yaml | 282 +++++++ go.mod | 46 ++ go.sum | 83 ++ main.go | 175 ++++ messagetracking.go | 306 +++++++ portal.go | 995 +++++++++++++++++++++++ puppet.go | 280 +++++++ segment.go | 97 +++ user.go | 703 ++++++++++++++++ 25 files changed, 4547 insertions(+), 1 deletion(-) create mode 100644 bridgestate.go create mode 100644 commands.go create mode 100644 config/bridge.go create mode 100644 config/config.go create mode 100644 config/upgrade.go create mode 100644 custompuppet.go create mode 100644 database/database.go create mode 100644 database/message.go create mode 100644 database/portal.go create mode 100644 database/puppet.go create mode 100644 database/upgrades/00-latest-revision.sql create mode 100644 database/upgrades/upgrades.go create mode 100644 database/user.go create mode 100755 docker-run.sh create mode 100644 example-config.yaml create mode 100644 go.sum create mode 100644 main.go create mode 100644 messagetracking.go create mode 100644 portal.go create mode 100644 puppet.go create mode 100644 segment.go create mode 100644 user.go diff --git a/.gitignore b/.gitignore index e568e98..3ea12b6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ *.json *.db *.log +*.log.gz /mautrix-gmessages diff --git a/Dockerfile b/Dockerfile index 9a026aa..cd10ae6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ ENV UID=1337 \ RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl COPY --from=builder /usr/bin/mautrix-gmessages /usr/bin/mautrix-gmessages -COPY --from=builder /build/example-config.yaml /opt/mautrix-whatsapp/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data diff --git a/bridgestate.go b/bridgestate.go new file mode 100644 index 0000000..1451daa --- /dev/null +++ b/bridgestate.go @@ -0,0 +1,94 @@ +// mautrix-gmessages - A Matrix-Google Messages 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 ( + "maunium.net/go/mautrix/bridge/status" +) + +const ( + WALoggedOut status.BridgeStateErrorCode = "wa-logged-out" + WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone" + WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout" + WANotConnected status.BridgeStateErrorCode = "wa-not-connected" + WAConnecting status.BridgeStateErrorCode = "wa-connecting" + WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout" + WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline" + WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed" + WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect" +) + +func init() { + status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ + WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.", + WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.", + WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.", + WANotConnected: "You're not connected to WhatsApp", + WAConnecting: "Reconnecting to WhatsApp...", + WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.", + WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.", + WAConnectionFailed: "Connecting to the WhatsApp web servers failed.", + WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.", + }) +} + +func (user *User) GetRemoteID() string { + if user == nil { + return "" + } + return user.Phone +} + +func (user *User) GetRemoteName() string { + return "" +} + +/*func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) { + if !prov.bridge.AS.CheckServerToken(w, r) { + return + } + userID := r.URL.Query().Get("user_id") + user := prov.bridge.GetUserByMXID(id.UserID(userID)) + var global status.BridgeState + global.StateEvent = status.StateRunning + var remote status.BridgeState + if user.IsConnected() { + if user.Client.IsLoggedIn() { + remote.StateEvent = status.StateConnected + } else if user.Session != nil { + remote.StateEvent = status.StateConnecting + remote.Error = WAConnecting + } // else: unconfigured + } else if user.Session != nil { + remote.StateEvent = status.StateBadCredentials + remote.Error = WANotConnected + } // else: unconfigured + global = global.Fill(nil) + resp := status.GlobalBridgeState{ + BridgeState: global, + RemoteStates: map[string]status.BridgeState{}, + } + if len(remote.StateEvent) > 0 { + remote = remote.Fill(user) + resp.RemoteStates[remote.RemoteID] = remote + } + user.log.Debugfln("Responding bridge state in bridge status endpoint: %+v", resp) + jsonResponse(w, http.StatusOK, &resp) + if len(resp.RemoteStates) > 0 { + user.BridgeState.SetPrev(remote) + } +}*/ diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..7645f17 --- /dev/null +++ b/commands.go @@ -0,0 +1,321 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + + "github.com/skip2/go-qrcode" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridge/commands" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type WrappedCommandEvent struct { + *commands.Event + Bridge *GMBridge + User *User + Portal *Portal +} + +func (br *GMBridge) RegisterCommands() { + proc := br.CommandProcessor.(*commands.Processor) + proc.AddHandlers( + cmdLogin, + cmdDeleteSession, + cmdReconnect, + cmdDisconnect, + cmdPing, + cmdDeletePortal, + cmdDeleteAllPortals, + ) +} + +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.(*GMBridge) + handler(&WrappedCommandEvent{ce, br, user, portal}) + } +} + +var ( + HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11} + HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} +) + +var cmdLogin = &commands.FullHandler{ + Func: wrapCommand(fnLogin), + Name: "login", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAuth, + Description: "Link the bridge to Google Messages on your Android phone as a web client.", + }, +} + +func fnLogin(ce *WrappedCommandEvent) { + if ce.User.Session != nil { + if ce.User.IsConnected() { + ce.Reply("You're already logged in") + } else { + ce.Reply("You're already logged in. Perhaps you wanted to `reconnect`?") + } + return + } + + _, err := ce.User.Login(context.Background()) + if err != nil { + ce.User.log.Errorf("Failed to log in:", err) + ce.Reply("Failed to log in: %v", err) + return + } +} + +func (user *User) sendQR(ce *WrappedCommandEvent, code string, prevEvent id.EventID) id.EventID { + url, ok := user.uploadQR(ce, code) + if !ok { + return prevEvent + } + content := event.MessageEventContent{ + MsgType: event.MsgImage, + Body: code, + URL: url.CUString(), + } + if len(prevEvent) != 0 { + content.SetEdit(prevEvent) + } + resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content) + if err != nil { + user.log.Errorln("Failed to send edited QR code to user:", err) + } else if len(prevEvent) == 0 { + prevEvent = resp.EventID + } + return prevEvent +} + +func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) { + qrCode, err := qrcode.Encode(code, qrcode.Low, 256) + if err != nil { + user.log.Errorln("Failed to encode QR code:", err) + ce.Reply("Failed to encode QR code: %v", err) + return id.ContentURI{}, false + } + + bot := user.bridge.AS.BotClient() + + resp, err := bot.UploadBytes(qrCode, "image/png") + if err != nil { + user.log.Errorln("Failed to upload QR code:", err) + ce.Reply("Failed to upload QR code: %v", err) + return id.ContentURI{}, false + } + return resp.ContentURI, true +} + +var cmdDeleteSession = &commands.FullHandler{ + Func: wrapCommand(fnDeleteSession), + Name: "delete-session", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAuth, + Description: "Delete session information and disconnect from Google Messages without sending a logout request.", + }, +} + +func fnDeleteSession(ce *WrappedCommandEvent) { + if ce.User.Session == nil && ce.User.Client == nil { + ce.Reply("Nothing to purge: no session information stored and no active connection.") + return + } + ce.User.removeFromPhoneMap(status.BridgeState{StateEvent: status.StateLoggedOut}) + ce.User.DeleteConnection() + ce.User.DeleteSession() + ce.Reply("Session information purged") +} + +var cmdReconnect = &commands.FullHandler{ + Func: wrapCommand(fnReconnect), + Name: "reconnect", + Help: commands.HelpMeta{ + Section: HelpSectionConnectionManagement, + Description: "Reconnect to Google Messages.", + }, +} + +func fnReconnect(ce *WrappedCommandEvent) { + if ce.User.Client == nil { + if ce.User.Session == nil { + ce.Reply("You're not logged into Google Messages. Please log in first.") + } else { + ce.User.Connect() + ce.Reply("Started connecting to Google Messages") + } + } else { + ce.User.DeleteConnection() + ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected}) + ce.User.Connect() + ce.Reply("Restarted connection to Google Messages") + } +} + +var cmdDisconnect = &commands.FullHandler{ + Func: wrapCommand(fnDisconnect), + Name: "disconnect", + Help: commands.HelpMeta{ + Section: HelpSectionConnectionManagement, + Description: "Disconnect from Google Messages (without logging out).", + }, +} + +func fnDisconnect(ce *WrappedCommandEvent) { + if ce.User.Client == nil { + ce.Reply("You don't have a Google Messages connection.") + return + } + ce.User.DeleteConnection() + ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") + ce.User.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected}) +} + +var cmdPing = &commands.FullHandler{ + Func: wrapCommand(fnPing), + Name: "ping", + Help: commands.HelpMeta{ + Section: HelpSectionConnectionManagement, + Description: "Check your connection to Google Messages.", + }, +} + +func fnPing(ce *WrappedCommandEvent) { + if ce.User.Session == nil { + if ce.User.Client != nil { + ce.Reply("Connected to Google Messages, but not logged in.") + } else { + ce.Reply("You're not logged into Google Messages.") + } + } else if ce.User.Client == nil || !ce.User.Client.IsConnected() { + ce.Reply("You're logged in as %s (device #%d), but you don't have a Google Messages connection.", ce.User.Phone) + } else { + ce.Reply("Logged in as %s, connection to Google Messages may be OK", ce.User.Phone) + } +} + +func canDeletePortal(portal *Portal, userID id.UserID) bool { + if len(portal.MXID) == 0 { + return false + } + + members, err := portal.MainIntent().JoinedMembers(portal.MXID) + if err != nil { + portal.log.Errorfln("Failed to get joined members to check if portal can be deleted by %s: %v", userID, err) + 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.Session != nil { + return false + } + } + return true +} + +var cmdDeletePortal = &commands.FullHandler{ + Func: wrapCommand(fnDeletePortal), + Name: "delete-portal", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.", + }, + RequiresPortal: true, +} + +func fnDeletePortal(ce *WrappedCommandEvent) { + if !ce.User.Admin && !canDeletePortal(ce.Portal, ce.User.MXID) { + ce.Reply("Only bridge admins can delete portals with other Matrix users") + return + } + + ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") + ce.Portal.Delete() + ce.Portal.Cleanup(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.GetAllPortalsForUser(ce.User.RowID) + if len(portals) == 0 { + ce.Reply("Didn't find any portals to delete") + return + } + + leave := func(portal *Portal) { + if len(portal.MXID) > 0 { + _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ + Reason: "Deleting portal", + UserID: ce.User.MXID, + }) + } + } + intent := ce.User.DoublePuppetIntent + if intent != nil { + leave = func(portal *Portal) { + if len(portal.MXID) > 0 { + _, _ = intent.LeaveRoom(portal.MXID) + _, _ = intent.ForgetRoom(portal.MXID) + } + } + } + roomYeeting := ce.Bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) + if roomYeeting { + leave = func(portal *Portal) { + portal.Cleanup(false) + } + } + ce.Reply("Found %d portals, deleting...", len(portals)) + for _, portal := range portals { + portal.Delete() + leave(portal) + } + if !roomYeeting { + ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") + go func() { + for _, portal := range portals { + portal.Cleanup(false) + } + ce.Reply("Finished background cleanup of deleted portal rooms.") + }() + } else { + ce.Reply("Finished deleting portals.") + } +} diff --git a/config/bridge.go b/config/bridge.go new file mode 100644 index 0000000..8826414 --- /dev/null +++ b/config/bridge.go @@ -0,0 +1,159 @@ +// mautrix-gmessages - A Matrix-Google Messages puppeting bridge. +// Copyright (C) 2023 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "errors" + "fmt" + "strings" + "text/template" + + "maunium.net/go/mautrix/bridge/bridgeconfig" +) + +type BridgeConfig struct { + UsernameTemplate string `yaml:"username_template"` + DisplaynameTemplate string `yaml:"displayname_template"` + + PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + + DeliveryReceipts bool `yaml:"delivery_receipts"` + MessageStatusEvents bool `yaml:"message_status_events"` + MessageErrorNotices bool `yaml:"message_error_notices"` + PortalMessageBuffer int `yaml:"portal_message_buffer"` + + SyncDirectChatList bool `yaml:"sync_direct_chat_list"` + + DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"` + DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"` + LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"` + + PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` + BridgeNotices bool `yaml:"bridge_notices"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + MuteBridging bool `yaml:"mute_bridging"` + ArchiveTag string `yaml:"archive_tag"` + PinnedTag string `yaml:"pinned_tag"` + TagOnlyOnCreate bool `yaml:"tag_only_on_create"` + FederateRooms bool `yaml:"federate_rooms"` + CaptionInMessage bool `yaml:"caption_in_message"` + + DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"` + + 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"` + } `yaml:"provisioning"` + + Permissions bridgeconfig.PermissionConfig `yaml:"permissions"` + + ParsedUsernameTemplate *template.Template `yaml:"-"` + displaynameTemplate *template.Template `yaml:"-"` +} + +func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { + return bc.Encryption +} + +func (bc BridgeConfig) EnableMessageStatusEvents() bool { + return bc.MessageStatusEvents +} + +func (bc BridgeConfig) EnableMessageErrorNotices() bool { + return bc.MessageErrorNotices +} + +func (bc BridgeConfig) GetCommandPrefix() string { + return bc.CommandPrefix +} + +func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { + return bc.ManagementRoomText +} + +func (bc BridgeConfig) GetResendBridgeInfo() bool { + return bc.ResendBridgeInfo +} + +func boolToInt(val bool) int { + if val { + return 1 + } + return 0 +} + +func (bc BridgeConfig) Validate() error { + _, hasWildcard := bc.Permissions["*"] + _, hasExampleDomain := bc.Permissions["example.com"] + _, hasExampleUser := bc.Permissions["@admin:example.com"] + exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) + if len(bc.Permissions) <= exampleLen { + return errors.New("bridge.permissions not configured") + } + return nil +} + +type umBridgeConfig BridgeConfig + +func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + err := unmarshal((*umBridgeConfig)(bc)) + if err != nil { + return err + } + + bc.ParsedUsernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate) + if err != nil { + return err + } else if !strings.Contains(bc.FormatUsername("1.1234567890"), "1.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 +} + +type DisplaynameTemplateArgs struct { + PhoneNumber string + FullName string + FirstName string +} + +func (bc BridgeConfig) FormatDisplayname(phone, fullName, firstName string) string { + var buf strings.Builder + _ = bc.displaynameTemplate.Execute(&buf, DisplaynameTemplateArgs{ + PhoneNumber: phone, + FullName: fullName, + FirstName: firstName, + }) + return buf.String() +} + +func (bc BridgeConfig) FormatUsername(username string) string { + var buf strings.Builder + _ = bc.ParsedUsernameTemplate.Execute(&buf, username) + return buf.String() +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..a308c12 --- /dev/null +++ b/config/config.go @@ -0,0 +1,42 @@ +// mautrix-gmessages - A Matrix-Google Messages puppeting bridge. +// Copyright (C) 2023 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "maunium.net/go/mautrix/bridge/bridgeconfig" + "maunium.net/go/mautrix/id" +) + +type Config struct { + *bridgeconfig.BaseConfig `yaml:",inline"` + + SegmentKey string `yaml:"segment_key"` + SegmentUserID string `yaml:"segment_user_id"` + + Metrics struct { + Enabled bool `yaml:"enabled"` + Listen string `yaml:"listen"` + } `yaml:"metrics"` + + Bridge BridgeConfig `yaml:"bridge"` +} + +func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool { + _, homeserver, _ := userID.Parse() + _, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver] + return hasSecret +} diff --git a/config/upgrade.go b/config/upgrade.go new file mode 100644 index 0000000..2992444 --- /dev/null +++ b/config/upgrade.go @@ -0,0 +1,108 @@ +// mautrix-gmessages - A Matrix-Google Messages puppeting bridge. +// Copyright (C) 2023 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "maunium.net/go/mautrix/bridge/bridgeconfig" + "maunium.net/go/mautrix/util" + up "maunium.net/go/mautrix/util/configupgrade" +) + +func DoUpgrade(helper *up.Helper) { + bridgeconfig.Upgrader.DoUpgrade(helper) + + helper.Copy(up.Str|up.Null, "segment_key") + helper.Copy(up.Str|up.Null, "segment_user_id") + + helper.Copy(up.Bool, "metrics", "enabled") + helper.Copy(up.Str, "metrics", "listen") + + helper.Copy(up.Str, "bridge", "username_template") + helper.Copy(up.Str, "bridge", "displayname_template") + helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") + helper.Copy(up.Bool, "bridge", "delivery_receipts") + helper.Copy(up.Bool, "bridge", "message_status_events") + helper.Copy(up.Bool, "bridge", "message_error_notices") + helper.Copy(up.Int, "bridge", "portal_message_buffer") + helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") + 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", "private_chat_portal_meta") + helper.Copy(up.Bool, "bridge", "bridge_notices") + helper.Copy(up.Bool, "bridge", "resend_bridge_info") + helper.Copy(up.Bool, "bridge", "mute_bridging") + helper.Copy(up.Str|up.Null, "bridge", "archive_tag") + helper.Copy(up.Str|up.Null, "bridge", "pinned_tag") + helper.Copy(up.Bool, "bridge", "tag_only_on_create") + helper.Copy(up.Str, "bridge", "command_prefix") + helper.Copy(up.Bool, "bridge", "federate_rooms") + helper.Copy(up.Bool, "bridge", "disable_bridge_alerts") + helper.Copy(up.Bool, "bridge", "caption_in_message") + + 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.Str, "bridge", "provisioning", "prefix") + if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { + sharedSecret := util.RandomString(64) + helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret") + } else { + helper.Copy(up.Str, "bridge", "provisioning", "shared_secret") + } + helper.Copy(up.Map, "bridge", "permissions") +} + +var SpacedBlocks = [][]string{ + {"homeserver", "software"}, + {"appservice"}, + {"appservice", "hostname"}, + {"appservice", "database"}, + {"appservice", "id"}, + {"appservice", "as_token"}, + {"segment_key"}, + {"metrics"}, + {"bridge"}, + {"bridge", "command_prefix"}, + {"bridge", "management_room_text"}, + {"bridge", "encryption"}, + {"bridge", "provisioning"}, + {"bridge", "permissions"}, + {"logging"}, +} diff --git a/custompuppet.go b/custompuppet.go new file mode 100644 index 0000000..045d906 --- /dev/null +++ b/custompuppet.go @@ -0,0 +1,168 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "crypto/hmac" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridge" + "maunium.net/go/mautrix/id" +) + +var ( + ErrMismatchingMXID = errors.New("whoami result does not match custom mxid") +) + +var _ bridge.DoublePuppet = (*User)(nil) + +func (user *User) SwitchCustomMXID(accessToken string, mxid id.UserID) error { + if mxid != user.MXID { + return errors.New("mismatching mxid") + } + user.AccessToken = accessToken + return user.startCustomMXID(false) +} + +func (user *User) CustomIntent() *appservice.IntentAPI { + return user.DoublePuppetIntent +} + +func (user *User) loginWithSharedSecret() error { + _, homeserver, _ := user.MXID.Parse() + user.zlog.Debug().Msg("Logging into double puppet with shared secret") + loginSecret := user.bridge.Config.Bridge.LoginSharedSecretMap[homeserver] + client, err := user.bridge.newDoublePuppetClient(user.MXID, "") + if err != nil { + return err + } + req := mautrix.ReqLogin{ + Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(user.MXID)}, + DeviceID: "Google Messages Bridge", + InitialDeviceDisplayName: "Google Messages Bridge", + } + if loginSecret == "appservice" { + client.AccessToken = user.bridge.AS.Registration.AppToken + req.Type = mautrix.AuthTypeAppservice + } else { + mac := hmac.New(sha512.New, []byte(loginSecret)) + mac.Write([]byte(user.MXID)) + req.Password = hex.EncodeToString(mac.Sum(nil)) + req.Type = mautrix.AuthTypePassword + } + resp, err := client.Login(&req) + if err != nil { + return fmt.Errorf("failed to log in with shared secret: %w", err) + } + user.AccessToken = resp.AccessToken + err = user.Update(context.TODO()) + if err != nil { + return fmt.Errorf("failed to save access token: %w", err) + } + return nil +} + +func (br *GMBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) { + _, homeserver, err := mxid.Parse() + if err != nil { + return nil, err + } + homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver] + if !found { + if homeserver == br.AS.HomeserverDomain { + homeserverURL = "" + } else if br.Config.Bridge.DoublePuppetAllowDiscovery { + resp, err := mautrix.DiscoverClientAPI(homeserver) + if err != nil { + return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err) + } + homeserverURL = resp.Homeserver.BaseURL + br.ZLog.Debug(). + Str("server_name", homeserver). + Str("base_url", homeserverURL). + Str("user_id", mxid.String()). + Msg("Discovered homeserver URL to enable double puppeting for external user") + } else { + return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver) + } + } + return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL) +} + +func (user *User) newDoublePuppetIntent() (*appservice.IntentAPI, error) { + client, err := user.bridge.newDoublePuppetClient(user.MXID, user.AccessToken) + if err != nil { + return nil, err + } + + ia := user.bridge.AS.NewIntentAPI("custom") + ia.Client = client + ia.Localpart, _, _ = user.MXID.Parse() + ia.UserID = user.MXID + ia.IsCustomPuppet = true + return ia, nil +} + +func (user *User) clearCustomMXID() { + user.AccessToken = "" + user.DoublePuppetIntent = nil +} + +func (user *User) startCustomMXID(reloginOnFail bool) error { + if len(user.AccessToken) == 0 { + return nil + } + intent, err := user.newDoublePuppetIntent() + if err != nil { + user.clearCustomMXID() + return fmt.Errorf("failed to create double puppet intent: %w", err) + } + resp, err := intent.Whoami() + if err != nil { + if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !user.tryRelogin(err)) { + user.clearCustomMXID() + return fmt.Errorf("failed to ensure double puppet token is valid: %w", err) + } + intent.AccessToken = user.AccessToken + } + if resp.UserID != user.MXID { + user.clearCustomMXID() + return ErrMismatchingMXID + } + user.DoublePuppetIntent = intent + return nil +} + +func (user *User) tryRelogin(err error) bool { + if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) { + return false + } + user.zlog.Debug().Err(err).Msg("Trying to relogin after error in double puppet") + err = user.loginWithSharedSecret() + if err != nil { + user.zlog.Err(err).Msg("Failed to relogin after error in double puppet") + return false + } + user.zlog.Info().Msg("Successfully relogined after error in double puppet") + return true +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..f4beae9 --- /dev/null +++ b/database/database.go @@ -0,0 +1,77 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + + _ "github.com/mattn/go-sqlite3" + "maunium.net/go/mautrix/util/dbutil" + + "go.mau.fi/mautrix-gmessages/database/upgrades" +) + +type Database struct { + *dbutil.Database + + User *UserQuery + Portal *PortalQuery + Puppet *PuppetQuery + Message *MessageQuery +} + +func New(baseDB *dbutil.Database) *Database { + db := &Database{Database: baseDB} + db.UpgradeTable = upgrades.Table + db.User = &UserQuery{db: db} + db.Portal = &PortalQuery{db: db} + db.Puppet = &PuppetQuery{db: db} + db.Message = &MessageQuery{db: db} + return db +} + +type dataStruct[T any] interface { + Scan(row dbutil.Scannable) (T, error) +} + +type queryStruct[T dataStruct[T]] interface { + New() T + getDB() *Database +} + +func get[T dataStruct[T]](qs queryStruct[T], ctx context.Context, query string, args ...any) (T, error) { + return qs.New().Scan(qs.getDB().Conn(ctx).QueryRowContext(ctx, query, args...)) +} + +func getAll[T dataStruct[T]](qs queryStruct[T], ctx context.Context, query string, args ...any) ([]T, error) { + rows, err := qs.getDB().Conn(ctx).QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + items := make([]T, 0) + defer func() { + _ = rows.Close() + }() + for rows.Next() { + item, err := qs.New().Scan(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} diff --git a/database/message.go b/database/message.go new file mode 100644 index 0000000..ed9cf7a --- /dev/null +++ b/database/message.go @@ -0,0 +1,104 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "database/sql" + "errors" + "time" + + log "maunium.net/go/maulogger/v2" + + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/util/dbutil" +) + +type MessageQuery struct { + db *Database +} + +func (mq *MessageQuery) New() *Message { + return &Message{ + db: mq.db, + } +} + +func (mq *MessageQuery) getDB() *Database { + return mq.db +} + +const ( + getMessageByIDQuery = ` + SELECT conv_id, conv_receiver, id, mxid, sender, timestamp FROM message + WHERE conv_id=$1 AND conv_receiver=$2 AND id=$3 + ` + getMessageByMXIDQuery = ` + SELECT conv_id, conv_receiver, id, mxid, sender, timestamp FROM message + WHERE mxid=$1 + ` +) + +func (mq *MessageQuery) GetByID(ctx context.Context, chat Key, messageID string) (*Message, error) { + return get[*Message](mq, ctx, getMessageByIDQuery, chat.ID, chat.Receiver, messageID) +} + +func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) { + return get[*Message](mq, ctx, getMessageByMXIDQuery, mxid) +} + +type Message struct { + db *Database + log log.Logger + + Chat Key + ID string + MXID id.EventID + Sender string + Timestamp time.Time +} + +func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) { + var ts int64 + err := row.Scan(&msg.Chat.ID, &msg.Chat.Receiver, &msg.ID, &msg.MXID, &msg.Sender, &msg.Timestamp) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if ts != 0 { + msg.Timestamp = time.UnixMilli(ts) + } + return msg, nil +} + +func (msg *Message) sqlVariables() []any { + return []any{msg.Chat.ID, msg.Chat.Receiver, msg.ID, msg.MXID, msg.Sender, msg.Timestamp.UnixMilli()} +} + +func (msg *Message) Insert(ctx context.Context) error { + _, err := msg.db.Conn(ctx).ExecContext(ctx, ` + INSERT INTO message (conv_id, conv_receiver, id, mxid, sender, timestamp) + VALUES ($1, $2, $3, $4, $5, $6) + `, msg.sqlVariables()...) + return err +} + +func (msg *Message) Delete(ctx context.Context) error { + _, err := msg.db.Conn(ctx).ExecContext(ctx, "DELETE FROM message WHERE conv_id=$1 AND conv_receiver=$2 AND id=$3", msg.Chat.ID, msg.Chat.Receiver, msg.ID) + return err +} diff --git a/database/portal.go b/database/portal.go new file mode 100644 index 0000000..f488897 --- /dev/null +++ b/database/portal.go @@ -0,0 +1,138 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "database/sql" + "errors" + "fmt" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/util/dbutil" +) + +type PortalQuery struct { + db *Database +} + +func (pq *PortalQuery) New() *Portal { + return &Portal{ + db: pq.db, + } +} + +func (pq *PortalQuery) getDB() *Database { + return pq.db +} + +func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) { + return getAll[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal") +} + +func (pq *PortalQuery) GetAllForUser(ctx context.Context, receiver int) ([]*Portal, error) { + return getAll[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE receiver=$1", receiver) +} + +func (pq *PortalQuery) GetByKey(ctx context.Context, key Key) (*Portal, error) { + return get[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE id=$1 AND receiver=$2", key.ID, key.Receiver) +} + +func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { + return get[*Portal](pq, ctx, "SELECT id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space FROM portal WHERE mxid=$1", mxid) +} + +type Key struct { + ID string + Receiver int +} + +func (p Key) String() string { + return fmt.Sprintf("%d.%s", p.Receiver, p.ID) +} + +func (p Key) MarshalZerologObject(e *zerolog.Event) { + e.Str("id", p.ID).Int("receiver", p.Receiver) +} + +type Portal struct { + db *Database + + Key + SelfUserID string + OtherUserID string + MXID id.RoomID + + Name string + NameSet bool + AvatarID string + AvatarMXC id.ContentURI + AvatarSet bool + Encrypted bool + InSpace bool +} + +func (portal *Portal) Scan(row dbutil.Scannable) (*Portal, error) { + var mxid, selfUserID, otherUserID sql.NullString + err := row.Scan(&portal.ID, &portal.Receiver, &selfUserID, &otherUserID, &mxid, &portal.Name, &portal.NameSet, &portal.AvatarID, &portal.AvatarMXC, &portal.AvatarSet, &portal.Encrypted, &portal.InSpace) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + portal.MXID = id.RoomID(mxid.String) + portal.SelfUserID = selfUserID.String + portal.OtherUserID = otherUserID.String + return portal, nil +} + +func (portal *Portal) sqlVariables() []any { + var mxid, selfUserID, otherUserID *string + if portal.MXID != "" { + mxid = (*string)(&portal.MXID) + } + if portal.SelfUserID != "" { + selfUserID = &portal.SelfUserID + } + if portal.OtherUserID != "" { + otherUserID = &portal.OtherUserID + } + return []any{portal.ID, portal.Receiver, selfUserID, otherUserID, mxid, portal.Name, portal.NameSet, portal.AvatarID, portal.AvatarMXC, portal.AvatarSet, portal.Encrypted, portal.InSpace} +} + +func (portal *Portal) Insert(ctx context.Context) error { + _, err := portal.db.Conn(ctx).ExecContext(ctx, ` + INSERT INTO portal (id, receiver, self_user, other_user, mxid, name, name_set, avatar_id, avatar_mxc, avatar_set, encrypted, in_space) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `, portal.sqlVariables()...) + return err +} + +func (portal *Portal) Update(ctx context.Context) error { + _, err := portal.db.Conn(ctx).ExecContext(ctx, ` + UPDATE portal + SET self_user=$3, other_user=$4, mxid=$5, name=$6, name_set=$7, avatar_id=$8, avatar_mxc=$9, avatar_set=$10, encrypted=$11, in_space=$12 + WHERE id=$1 AND receiver=$2 + `, portal.sqlVariables()...) + return err +} + +func (portal *Portal) Delete(ctx context.Context) error { + _, err := portal.db.Conn(ctx).ExecContext(ctx, "DELETE FROM portal WHERE id=$1 AND receiver=$2", portal.ID, portal.Receiver) + return err +} diff --git a/database/puppet.go b/database/puppet.go new file mode 100644 index 0000000..ea7a4ac --- /dev/null +++ b/database/puppet.go @@ -0,0 +1,92 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "database/sql" + "errors" + + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/util/dbutil" +) + +type PuppetQuery struct { + db *Database +} + +func (pq *PuppetQuery) New() *Puppet { + return &Puppet{ + db: pq.db, + } +} + +func (pq *PuppetQuery) getDB() *Database { + return pq.db +} + +func (pq *PuppetQuery) GetAll(ctx context.Context) ([]*Puppet, error) { + return getAll[*Puppet](pq, ctx, "SELECT id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set FROM puppet") +} + +func (pq *PuppetQuery) Get(ctx context.Context, key Key) (*Puppet, error) { + return get[*Puppet](pq, ctx, "SELECT id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set FROM puppet WHERE phone=$1 AND receiver=$2", key.ID, key.Receiver) +} + +type Puppet struct { + db *Database + + Key + Phone string + Name string + NameSet bool + AvatarID string + AvatarMXC id.ContentURI + AvatarSet bool + ContactInfoSet bool +} + +func (puppet *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { + err := row.Scan(&puppet.ID, &puppet.Receiver, &puppet.Phone, &puppet.Name, &puppet.NameSet, &puppet.AvatarID, &puppet.AvatarMXC, &puppet.AvatarSet, &puppet.ContactInfoSet) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + return puppet, nil +} + +func (puppet *Puppet) sqlVariables() []any { + return []any{puppet.ID, puppet.Receiver, puppet.Phone, puppet.Name, puppet.NameSet, puppet.AvatarID, puppet.AvatarMXC, puppet.AvatarSet, puppet.ContactInfoSet} +} + +func (puppet *Puppet) Insert(ctx context.Context) error { + _, err := puppet.db.Conn(ctx).ExecContext(ctx, ` + INSERT INTO puppet (id, receiver, phone, name, name_set, avatar_id, avatar_mxc, avatar_set, contact_info_set) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, puppet.sqlVariables()...) + return err +} + +func (puppet *Puppet) Update(ctx context.Context) error { + _, err := puppet.db.Conn(ctx).ExecContext(ctx, ` + UPDATE puppet + SET phone=$3, name=$4, name_set=$5, avatar_id=$6, avatar_mxc=$7, avatar_set=$8, contact_info_set=$9 + WHERE id=$1 AND receiver=$2 + `, puppet.sqlVariables()...) + return err +} diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql new file mode 100644 index 0000000..c16e1b7 --- /dev/null +++ b/database/upgrades/00-latest-revision.sql @@ -0,0 +1,64 @@ +-- v0 -> v1: Latest revision + +CREATE TABLE "user" ( + -- only: postgres + rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + -- only: sqlite + rowid INTEGER PRIMARY KEY, + + mxid TEXT NOT NULL UNIQUE, + phone TEXT UNIQUE, + session jsonb, + + management_room TEXT, + space_room TEXT, + + access_token TEXT +); + +CREATE TABLE puppet ( + id TEXT NOT NULL, + receiver BIGINT NOT NULL, + phone TEXT NOT NULL, + name TEXT NOT NULL, + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_id TEXT NOT NULL, + avatar_mxc TEXT NOT NULL, + avatar_set BOOLEAN NOT NULL DEFAULT false, + contact_info_set BOOLEAN NOT NULL DEFAULT false, + + FOREIGN KEY (receiver) REFERENCES "user"(rowid) ON DELETE CASCADE, + UNIQUE (phone, receiver), + PRIMARY KEY (id, receiver) +); + +CREATE TABLE portal ( + id TEXT NOT NULL, + receiver BIGINT NOT NULL, + self_user TEXT, + other_user TEXT, + mxid TEXT UNIQUE, + name TEXT NOT NULL, + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_id TEXT NOT NULL, + avatar_mxc TEXT NOT NULL, + avatar_set BOOLEAN NOT NULL DEFAULT false, + encrypted BOOLEAN NOT NULL DEFAULT false, + in_space BOOLEAN NOT NULL DEFAULT false, + + FOREIGN KEY (receiver) REFERENCES "user"(rowid) ON DELETE CASCADE, + FOREIGN KEY (other_user, receiver) REFERENCES puppet(id, receiver) ON DELETE CASCADE, + PRIMARY KEY (id, receiver) +); + +CREATE TABLE message ( + conv_id TEXT NOT NULL, + conv_receiver BIGINT NOT NULL, + id TEXT NOT NULL, + mxid TEXT NOT NULL UNIQUE, + sender TEXT NOT NULL, + timestamp BIGINT NOT NULL, + + PRIMARY KEY (conv_id, conv_receiver, id), + FOREIGN KEY (conv_id, conv_receiver) REFERENCES portal(id, receiver) ON DELETE CASCADE +); diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go new file mode 100644 index 0000000..4bea89a --- /dev/null +++ b/database/upgrades/upgrades.go @@ -0,0 +1,32 @@ +// mautrix-gmessages - A Matrix-Google Messages 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 upgrades + +import ( + "embed" + + "maunium.net/go/mautrix/util/dbutil" +) + +var Table dbutil.UpgradeTable + +//go:embed *.sql +var rawUpgrades embed.FS + +func init() { + Table.RegisterFS(rawUpgrades) +} diff --git a/database/user.go b/database/user.go new file mode 100644 index 0000000..da3d9e6 --- /dev/null +++ b/database/user.go @@ -0,0 +1,144 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/util/dbutil" + + "go.mau.fi/mautrix-gmessages/libgm/binary" +) + +type UserQuery struct { + db *Database +} + +func (uq *UserQuery) New() *User { + return &User{ + db: uq.db, + } +} + +func (uq *UserQuery) getDB() *Database { + return uq.db +} + +func (uq *UserQuery) GetAllWithSession(ctx context.Context) ([]*User, error) { + return getAll[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE phone<>'' AND session IS NOT NULL`) +} + +func (uq *UserQuery) GetAllWithDoublePuppet(ctx context.Context) ([]*User, error) { + return getAll[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE access_token<>''`) +} + +func (uq *UserQuery) GetByRowID(ctx context.Context, rowID int) (*User, error) { + return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE rowid=$1`, rowID) +} + +func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) { + return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE mxid=$1`, userID) +} + +func (uq *UserQuery) GetByPhone(ctx context.Context, phone string) (*User, error) { + return get[*User](uq, ctx, `SELECT rowid, mxid, phone, session, management_room, space_room, access_token FROM "user" WHERE phone=$1`, phone) +} + +type Session struct { + WebAuthKey []byte `json:"web_auth_key"` + AESKey []byte `json:"aes_key"` + HMACKey []byte `json:"hmac_key"` + + PhoneInfo *binary.Device `json:"phone_info"` + BrowserInfo *binary.Device `json:"browser_info"` +} + +type User struct { + db *Database + + RowID int + MXID id.UserID + Phone string + Session *Session + + ManagementRoom id.RoomID + SpaceRoom id.RoomID + + AccessToken string +} + +func (user *User) Scan(row dbutil.Scannable) (*User, error) { + var phone, session, managementRoom, spaceRoom, accessToken sql.NullString + err := row.Scan(&user.RowID, &user.MXID, &phone, &session, &managementRoom, &spaceRoom, &accessToken) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if session.String != "" { + var sess Session + err = json.Unmarshal([]byte(session.String), &sess) + if err != nil { + return nil, fmt.Errorf("failed to parse session: %w", err) + } + user.Session = &sess + } + user.Phone = phone.String + user.AccessToken = accessToken.String + user.ManagementRoom = id.RoomID(managementRoom.String) + user.SpaceRoom = id.RoomID(spaceRoom.String) + return user, nil +} + +func (user *User) sqlVariables() []any { + var phone, session, managementRoom, spaceRoom, accessToken *string + if user.Phone != "" { + phone = &user.Phone + } + if user.Session != nil { + data, _ := json.Marshal(user.Session) + strData := string(data) + session = &strData + } + if user.ManagementRoom != "" { + managementRoom = (*string)(&user.ManagementRoom) + } + if user.SpaceRoom != "" { + spaceRoom = (*string)(&user.SpaceRoom) + } + if user.AccessToken != "" { + accessToken = &user.AccessToken + } + return []any{user.MXID, phone, session, managementRoom, spaceRoom, accessToken} +} + +func (user *User) Insert(ctx context.Context) error { + err := user.db.Conn(ctx). + QueryRowContext(ctx, `INSERT INTO "user" (mxid, phone, session, management_room, space_room, access_token) VALUES ($1, $2, $3, $4, $5, $6) RETURNING rowid`, user.sqlVariables()...). + Scan(&user.RowID) + return err +} + +func (user *User) Update(ctx context.Context) error { + _, err := user.db.Conn(ctx).ExecContext(ctx, `UPDATE "user" SET phone=$2, session=$3, management_room=$4, space_room=$5, access_token=$6 WHERE mxid=$1`, user.sqlVariables()...) + return err +} diff --git a/docker-run.sh b/docker-run.sh new file mode 100755 index 0000000..8d8f2cf --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +if [[ -z "$GID" ]]; then + GID="$UID" +fi + +# Define functions. +function fixperms { + chown -R $UID:$GID /data + + # /opt/mautrix-gmessages is read-only, so disable file logging if it's pointing there. + if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-gmessages.log" ]]; then + yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml + fi +} + +if [[ ! -f /data/config.yaml ]]; then + cp /opt/mautrix-gmessages/example-config.yaml /data/config.yaml + echo "Didn't find a config file." + echo "Copied default config file to /data/config.yaml" + echo "Modify that config file to your liking." + echo "Start the container again after that to generate the registration file." + exit +fi + +if [[ ! -f /data/registration.yaml ]]; then + /usr/bin/mautrix-gmessages -g -c /data/config.yaml -r /data/registration.yaml || exit $? + echo "Didn't find a registration file." + echo "Generated one for you." + echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." + exit +fi + +cd /data +fixperms +exec su-exec $UID:$GID /usr/bin/mautrix-gmessages diff --git a/example-config.yaml b/example-config.yaml new file mode 100644 index 0000000..3c78043 --- /dev/null +++ b/example-config.yaml @@ -0,0 +1,282 @@ +# 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 google messages 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:29336 + + # The hostname and port where this appservice should listen. + hostname: 0.0.0.0 + port: 29336 + + # 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: gmessages + # Appservice bot details. + bot: + # Username of the appservice bot. + username: gmessagesbot + # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty + # to leave display name/avatar as-is. + displayname: Google Messages bridge bot + avatar: mxc://maunium.net/yGOdcrJcwqARZqdzbfuxfhzb + + # Whether or not to receive ephemeral events via appservice transactions. + # Requires MSC2409 support (i.e. Synapse 1.22+). + ephemeral_events: true + + # Should incoming events be handled asynchronously? + # This may be necessary for large public instances with lots of messages going through. + # However, messages will not be guaranteed to be bridged in the same order they were sent in. + async_transactions: false + + # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. + as_token: "This value is generated when generating the registration" + hs_token: "This value is generated when generating the registration" + +# Segment API key to track some events, like provisioning API login and encryption errors. +segment_key: null +# Optional user_id to use when sending Segment events. If null, defaults to using mxID. +segment_user_id: null + +# Prometheus config. +metrics: + # Enable prometheus metrics? + enabled: false + # IP and port where the metrics listener should be. The path is always /metrics + listen: 127.0.0.1:8001 + +# Bridge config +bridge: + # Localpart template of MXIDs for SMS users. + # {{.}} is replaced with an identifier of the recipient. + username_template: gmessages_{{.}} + # Displayname template for SMS users. + # {{.FullName}} - Full name provided by the phone + # {{.FirstName}} - First name provided by the phone + # {{.PhoneNumber}} - Formatted phone number provided by the phone + displayname_template: "{{or .FullName .PhoneNumber}}" + # Should the bridge create a space for each logged-in user and add bridged rooms to it? + personal_filtering_spaces: true + # Should the bridge send a read receipt from the bridge bot when a message has been sent to the phone? + 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 + + portal_message_buffer: 128 + + # 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 + + # Servers to always allow double puppeting from + double_puppet_server_map: + example.com: https://example.com + # Allow using double puppeting from any server with a valid client .well-known file. + double_puppet_allow_discovery: false + # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth + # + # If set, double puppeting will be enabled automatically for local users + # instead of users having to find an access token and run `login-matrix` + # manually. + login_shared_secret_map: + example.com: foobar + + # Whether to explicitly set the avatar and room name for private chat portal rooms. + # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. + # If set to `always`, all DM rooms will have explicit names and avatars set. + # If set to `never`, DM rooms will never have names and avatars set. + private_chat_portal_meta: default + # Should Matrix m.notice-type messages be bridged? + bridge_notices: true + # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. + # This field will automatically be changed back to false after it, except if the config file is not writable. + resend_bridge_info: false + # When using double puppeting, should muted chats be muted in Matrix? + mute_bridging: false + # When using double puppeting, should archived chats be moved to a specific tag in Matrix? + # This can be set to a tag (e.g. m.lowpriority), or null to disable. + archive_tag: null + # Same as above, but for pinned chats. The favorite tag is called m.favourite + pinned_tag: null + # Should mute status and tags only be bridged when the portal room is created? + tag_only_on_create: true + # Whether or not created rooms should have federation enabled. + # If false, created portal rooms will never be federated. + federate_rooms: true + # Should the bridge never send alerts to the bridge management room? + # These are mostly things like the user being logged out. + disable_bridge_alerts: false + # Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552. + # This is currently not supported in most clients. + caption_in_message: false + + # The prefix for commands. Only required in non-management rooms. + command_prefix: "!gm" + + # 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 Google Messages 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 SMS to Matrix. + receive: unverified + # Minimum level that the bridge should accept for incoming Matrix messages. + send: unverified + # Minimum level that the bridge should require for accepting key requests. + share: cross-signed-tofu + # Options for Megolm room key rotation. These options allow you to + # configure the m.room.encryption event content. See: + # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for + # more information about that event. + rotation: + # Enable custom Megolm room key rotation settings. Note that these + # settings will only apply to rooms created after this option is + # set. + enable_custom: false + # The maximum number of milliseconds a session should be used + # before changing it. The Matrix spec recommends 604800000 (a week) + # as the default. + milliseconds: 604800000 + # The maximum number of messages that should be sent with a given a + # session before changing it. The Matrix spec recommends 100 as the + # default. + messages: 100 + + # Disable rotating keys when a user's devices change? + # You should not enable this option unless you understand all the implications. + disable_device_change_key_rotation: false + + # Settings for provisioning API + provisioning: + # Prefix for the provisioning API paths. + prefix: /_matrix/provision + # Shared secret for authentication. If set to "generate", a random secret will be generated, + # or if set to "disable", the provisioning API will be disabled. + shared_secret: generate + + # Permissions for using the bridge. + # Permitted values: + # user - Access to use the bridge to link their own Google Messages on android. + # 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 + +# 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-gmessages.log + max_size: 100 + max_backups: 10 + compress: true diff --git a/go.mod b/go.mod index 74f0649..9e438b0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,49 @@ module go.mau.fi/mautrix-gmessages go 1.20 + +require ( + github.com/mattn/go-sqlite3 v1.14.17 + github.com/rs/zerolog v1.29.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + go.mau.fi/mautrix-gmessages/libgm v0.1.0 + maunium.net/go/maulogger/v2 v2.4.1 + maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474 +) + +require ( + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/yuin/goldmark v1.5.4 // indirect + go.mau.fi/zeroconfig v0.1.2 // indirect + golang.org/x/crypto v0.10.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/net v0.11.0 // indirect + golang.org/x/sys v0.9.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect +) + +// Exclude some things that cause go.sum to explode +exclude ( + cloud.google.com/go v0.65.0 + github.com/prometheus/client_golang v1.12.1 + google.golang.org/appengine v1.6.6 +) + +replace go.mau.fi/mautrix-gmessages/libgm => ./libgm diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ee5b803 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= +go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= +maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= +maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= +maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= +maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474 h1:Pzopg6NL7qaNdG2iUf8dVnu9+tVEa82b44KJKmpb/NY= +maunium.net/go/mautrix v0.15.4-0.20230628151140-e99578a15474/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e803480 --- /dev/null +++ b/main.go @@ -0,0 +1,175 @@ +// mautrix-gmessages - A Matrix-Google Messages 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 ( + _ "embed" + "sync" + + "maunium.net/go/mautrix/bridge" + "maunium.net/go/mautrix/bridge/commands" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/util/configupgrade" + + "go.mau.fi/mautrix-gmessages/config" + "go.mau.fi/mautrix-gmessages/database" +) + +// Information to find out exactly which commit the bridge was built from. +// These are filled at build time with the -X linker flag. +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +//go:embed example-config.yaml +var ExampleConfig string + +type GMBridge struct { + bridge.Bridge + Config *config.Config + DB *database.Database + //Provisioning *ProvisioningAPI + + usersByMXID map[id.UserID]*User + usersByPhone map[string]*User + usersLock sync.Mutex + spaceRooms map[id.RoomID]*User + spaceRoomsLock sync.Mutex + managementRooms map[id.RoomID]*User + managementRoomsLock sync.Mutex + portalsByMXID map[id.RoomID]*Portal + portalsByKey map[database.Key]*Portal + portalsLock sync.Mutex + puppetsByKey map[database.Key]*Puppet + puppetsLock sync.Mutex +} + +func (br *GMBridge) Init() { + br.CommandProcessor = commands.NewProcessor(&br.Bridge) + br.RegisterCommands() + + Segment.log = br.ZLog.With().Str("component", "segment").Logger() + Segment.key = br.Config.SegmentKey + Segment.userID = br.Config.SegmentUserID + if Segment.IsEnabled() { + Segment.log.Info().Msg("Segment metrics are enabled") + if Segment.userID != "" { + Segment.log.Info().Str("user_id", Segment.userID).Msg("Overriding Segment user ID") + } + } + + br.DB = database.New(br.Bridge.DB) + + ss := br.Config.Bridge.Provisioning.SharedSecret + if len(ss) > 0 && ss != "disable" { + //br.Provisioning = &ProvisioningAPI{bridge: br} + } +} + +func (br *GMBridge) Start() { + //if br.Provisioning != nil { + // br.ZLog.Debug().Msg("Initializing provisioning API") + // br.Provisioning.Init() + //} + br.WaitWebsocketConnected() + go br.StartUsers() +} + +func (br *GMBridge) StartUsers() { + br.ZLog.Debug().Msg("Starting users") + foundAnySessions := false + for _, user := range br.GetAllUsersWithSession() { + foundAnySessions = true + go user.Connect() + } + if !foundAnySessions { + br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) + } + br.ZLog.Debug().Msg("Starting custom puppets") + for _, loopuser := range br.GetAllUsersWithDoublePuppet() { + go func(user *User) { + user.zlog.Debug().Msg("Starting double puppet") + err := user.startCustomMXID(true) + if err != nil { + user.zlog.Err(err).Msg("Failed to start double puppet") + } + }(loopuser) + } +} + +func (br *GMBridge) Stop() { + for _, user := range br.usersByPhone { + if user.Client == nil { + continue + } + br.ZLog.Debug().Str("user_id", user.MXID.String()).Msg("Disconnecting user") + user.Client.Disconnect() + } +} + +func (br *GMBridge) GetExampleConfig() string { + return ExampleConfig +} + +func (br *GMBridge) GetConfigPtr() interface{} { + br.Config = &config.Config{ + BaseConfig: &br.Bridge.Config, + } + br.Config.BaseConfig.Bridge = &br.Config.Bridge + return br.Config +} + +func main() { + br := &GMBridge{ + usersByMXID: make(map[id.UserID]*User), + usersByPhone: make(map[string]*User), + spaceRooms: make(map[id.RoomID]*User), + managementRooms: make(map[id.RoomID]*User), + portalsByMXID: make(map[id.RoomID]*Portal), + portalsByKey: make(map[database.Key]*Portal), + puppetsByKey: make(map[database.Key]*Puppet), + } + br.Bridge = bridge.Bridge{ + Name: "mautrix-gmessages", + URL: "https://github.com/mautrix/gmessages", + Description: "A Matrix-Google Messages puppeting bridge.", + Version: "0.1.0", + ProtocolName: "Google Messages", + BeeperServiceName: "googlesms", + BeeperNetworkName: "googlesms", + + CryptoPickleKey: "go.mau.fi/mautrix-gmessages", + + ConfigUpgrader: &configupgrade.StructUpgrader{ + SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), + Blocks: config.SpacedBlocks, + Base: ExampleConfig, + }, + + Child: br, + } + br.InitVersion(Tag, Commit, BuildTime) + + br.Main() +} + +func (br *GMBridge) CreatePrivatePortal(roomID id.RoomID, brUser bridge.User, brGhost bridge.Ghost) { + //TODO implement? +} diff --git a/messagetracking.go b/messagetracking.go new file mode 100644 index 0000000..29ee6a1 --- /dev/null +++ b/messagetracking.go @@ -0,0 +1,306 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "errors" + "fmt" + "sync" + "time" + + log "maunium.net/go/maulogger/v2" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var ( + errUserNotConnected = errors.New("you are not connected to WhatsApp") + errDifferentUser = errors.New("user is not the recipient of this private chat portal") + errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot") + errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") + errUnexpectedParsedContentType = errors.New("unexpected parsed content type") + errInvalidGeoURI = errors.New("invalid `geo:` URI in message") + errUnknownMsgType = errors.New("unknown msgtype") + errMediaUnsupportedType = errors.New("unsupported media type") + errTargetNotFound = errors.New("target event not found") + errReactionDatabaseNotFound = errors.New("reaction database entry not found") + errReactionTargetNotFound = errors.New("reaction target message not found") + errTargetIsFake = errors.New("target is a fake event") + errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else") + errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM") + errPollMissingQuestion = errors.New("poll message is missing question") + errPollDuplicateOption = errors.New("poll options must be unique") + + errEditUnknownTarget = errors.New("unknown edit target message") + errEditUnknownTargetType = errors.New("unsupported edited message type") + errEditDifferentSender = errors.New("can't edit message sent by another user") + errEditTooOld = errors.New("message is too old to be edited") + + errBroadcastReactionNotSupported = errors.New("reacting to status messages is not currently supported") + errBroadcastSendDisabled = errors.New("sending status messages is disabled") + + 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, errUnknownMsgType), + errors.Is(err, errInvalidGeoURI), + errors.Is(err, errBroadcastReactionNotSupported), + errors.Is(err, errBroadcastSendDisabled): + return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "" + case errors.Is(err, errMNoticeDisabled): + return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, "" + case errors.Is(err, errMediaUnsupportedType), + errors.Is(err, errPollMissingQuestion), + errors.Is(err, errPollDuplicateOption), + errors.Is(err, errEditDifferentSender), + errors.Is(err, errEditTooOld), + errors.Is(err, errEditUnknownTarget), + errors.Is(err, errEditUnknownTargetType): + return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error() + case errors.Is(err, errTimeoutBeforeHandling): + return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled" + case errors.Is(err, context.DeadlineExceeded): + return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled" + case errors.Is(err, errMessageTakingLong): + return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error() + case errors.Is(err, errTargetNotFound), + errors.Is(err, errTargetIsFake), + errors.Is(err, errReactionDatabaseNotFound), + errors.Is(err, errReactionTargetNotFound), + errors.Is(err, errReactionSentBySomeoneElse), + errors.Is(err, errDMSentByOtherUser): + return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "" + case errors.Is(err, errUserNotConnected): + return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "" + case errors.Is(err, errUserNotLoggedIn), + errors.Is(err, errDifferentUser): + return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, "" + default: + return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "" + } +} + +func (portal *Portal) sendErrorMessage(evt *event.Event, err error, msgType string, confirmed bool, editID id.EventID) id.EventID { + if !portal.bridge.Config.Bridge.MessageErrorNotices { + return "" + } + certainty := "may not have been" + if confirmed { + certainty = "was not" + } + 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(content) + if err != nil { + portal.log.Warnfln("Failed to send bridging error message:", err) + return "" + } + return resp.EventID +} + +func (portal *Portal) sendStatusEvent(evtID, lastRetry id.EventID, err error) { + 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, + }, + 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(portal.MXID, event.BeeperMessageStatus, &content) + if err != nil { + portal.log.Warnln("Failed to send message status event:", err) + } +} + +func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) { + if portal.bridge.Config.Bridge.DeliveryReceipts { + err := portal.bridge.Bot.SendReceipt(portal.MXID, eventID, event.ReceiptTypeRead, nil) + if err != nil { + portal.log.Debugfln("Failed to send delivery receipt for %s: %v", eventID, err) + } + } +} + +func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string, ms *metricSender) { + var msgType string + switch evt.Type { + case event.EventMessage: + msgType = "message" + case event.EventReaction: + msgType = "reaction" + case event.EventRedaction: + msgType = "redaction" + default: + msgType = "unknown event" + } + evtDescription := evt.ID.String() + if evt.Type == event.EventRedaction { + evtDescription += fmt.Sprintf(" of %s", evt.Redacts) + } + origEvtID := evt.ID + if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { + origEvtID = retryMeta.OriginalEventID + } + if err != nil { + level := log.LevelError + if part == "Ignoring" { + level = log.LevelDebug + } + portal.log.Logfln(level, "%s %s %s from %s: %v", part, msgType, evtDescription, evt.Sender, err) + 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(evt, err, msgType, isCertain, ms.getNoticeID())) + } + portal.sendStatusEvent(origEvtID, evt.ID, err) + } else { + portal.log.Debugfln("Handled Matrix %s %s", msgType, evtDescription) + portal.sendDeliveryReceipt(evt.ID) + portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum()) + portal.sendStatusEvent(origEvtID, evt.ID, nil) + if prevNotice := ms.popNoticeID(); prevNotice != "" { + _, _ = portal.MainIntent().RedactEvent(portal.MXID, prevNotice, mautrix.ReqRedact{ + Reason: "error resolved", + }) + } + } + if ms != nil { + portal.log.Debugfln("Timings for %s: %s", evt.ID, ms.timings.String()) + } +} + +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) String() string { + mt.initReceive = niceRound(mt.initReceive) + mt.decrypt = niceRound(mt.decrypt) + mt.portalQueue = niceRound(mt.portalQueue) + mt.totalReceive = niceRound(mt.totalReceive) + mt.implicitRR = niceRound(mt.implicitRR) + mt.preproc = niceRound(mt.preproc) + mt.convert = niceRound(mt.convert) + mt.totalSend = niceRound(mt.totalSend) + return fmt.Sprintf("BRIDGE: receive: %s, decrypt: %s, queue: %s, total hs->portal: %s, implicit rr: %s -- PORTAL: preprocess: %s, convert: %s, total send: %s", mt.initReceive, mt.decrypt, mt.implicitRR, mt.portalQueue, mt.totalReceive, mt.preproc, mt.convert, mt.totalSend) +} + +type metricSender struct { + portal *Portal + previousNotice id.EventID + lock sync.Mutex + completed bool + retryNum int + timings *messageTimings +} + +func (ms *metricSender) getRetryNum() int { + if ms != nil { + return ms.retryNum + } + return 0 +} + +func (ms *metricSender) getNoticeID() id.EventID { + if ms == nil { + return "" + } + return ms.previousNotice +} + +func (ms *metricSender) popNoticeID() id.EventID { + if ms == nil { + return "" + } + evtID := ms.previousNotice + ms.previousNotice = "" + return evtID +} + +func (ms *metricSender) setNoticeID(evtID id.EventID) { + if ms != nil && ms.previousNotice == "" { + ms.previousNotice = evtID + } +} + +func (ms *metricSender) sendMessageMetrics(evt *event.Event, err error, part string, completed bool) { + ms.lock.Lock() + defer ms.lock.Unlock() + if !completed && ms.completed { + return + } + ms.portal.sendMessageMetrics(evt, err, part, ms) + ms.retryNum++ + ms.completed = completed +} diff --git a/portal.go b/portal.go new file mode 100644 index 0000000..214c3a3 --- /dev/null +++ b/portal.go @@ -0,0 +1,995 @@ +// mautrix-gmessages - A Matrix-Google Messages 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 ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/gif" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + log "maunium.net/go/maulogger/v2" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridge" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-gmessages/database" + "go.mau.fi/mautrix-gmessages/libgm/binary" + "go.mau.fi/mautrix-gmessages/libgm/util" +) + +func (br *GMBridge) 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).Str("mxid", mxid.String()).Msg("Failed to get portal from database") + return nil + } + return br.loadDBPortal(dbPortal, nil) + } + return portal +} + +func (br *GMBridge) GetIPortal(mxid id.RoomID) bridge.Portal { + p := br.GetPortalByMXID(mxid) + if p == nil { + return nil + } + return p +} + +func (portal *Portal) IsEncrypted() bool { + return portal.Encrypted +} + +func (portal *Portal) MarkEncrypted() { + portal.Encrypted = true + err := portal.Update(context.TODO()) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal to database after marking it as encrypted") + } +} + +func (portal *Portal) ReceiveMatrixEvent(brUser bridge.User, evt *event.Event) { + user := brUser.(*User) + if user.RowID == portal.Receiver { + portal.matrixMessages <- PortalMatrixMessage{user: user, evt: evt, receivedAt: time.Now()} + } +} + +func (br *GMBridge) GetPortalByKey(key database.Key) *Portal { + br.portalsLock.Lock() + defer br.portalsLock.Unlock() + portal, ok := br.portalsByKey[key] + if !ok { + dbPortal, err := br.DB.Portal.GetByKey(context.TODO(), key) + if err != nil { + br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to get portal from database") + return nil + } + return br.loadDBPortal(dbPortal, &key) + } + return portal +} + +func (br *GMBridge) GetExistingPortalByKey(key database.Key) *Portal { + br.portalsLock.Lock() + defer br.portalsLock.Unlock() + portal, ok := br.portalsByKey[key] + if !ok { + dbPortal, err := br.DB.Portal.GetByKey(context.TODO(), key) + if err != nil { + br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to get portal from database") + return nil + } + return br.loadDBPortal(dbPortal, nil) + } + return portal +} + +func (br *GMBridge) GetAllPortals() []*Portal { + return br.loadManyPortals(br.DB.Portal.GetAll) +} + +func (br *GMBridge) GetAllPortalsForUser(userID int) []*Portal { + return br.loadManyPortals(func(ctx context.Context) ([]*database.Portal, error) { + return br.DB.Portal.GetAllForUser(ctx, userID) + }) +} + +func (br *GMBridge) GetAllIPortals() (iportals []bridge.Portal) { + portals := br.GetAllPortals() + iportals = make([]bridge.Portal, len(portals)) + for i, portal := range portals { + iportals[i] = portal + } + return iportals +} + +func (br *GMBridge) loadManyPortals(query func(ctx context.Context) ([]*database.Portal, error)) []*Portal { + br.portalsLock.Lock() + defer br.portalsLock.Unlock() + dbPortals, err := query(context.TODO()) + if err != nil { + br.ZLog.Err(err).Msg("Failed to load all portals from database") + return []*Portal{} + } + output := make([]*Portal, len(dbPortals)) + for index, dbPortal := range dbPortals { + if dbPortal == nil { + continue + } + portal, ok := br.portalsByKey[dbPortal.Key] + if !ok { + portal = br.loadDBPortal(dbPortal, nil) + } + output[index] = portal + } + return output +} + +func (br *GMBridge) loadDBPortal(dbPortal *database.Portal, key *database.Key) *Portal { + if dbPortal == nil { + if key == nil { + return nil + } + dbPortal = br.DB.Portal.New() + dbPortal.Key = *key + err := dbPortal.Insert(context.TODO()) + if err != nil { + br.ZLog.Err(err).Object("portal_key", key).Msg("Failed to insert portal") + return nil + } + } + portal := br.NewPortal(dbPortal) + br.portalsByKey[portal.Key] = portal + if len(portal.MXID) > 0 { + br.portalsByMXID[portal.MXID] = portal + } + return portal +} + +func (portal *Portal) GetUsers() []*User { + return nil +} + +func (br *GMBridge) newBlankPortal(key database.Key) *Portal { + portal := &Portal{ + bridge: br, + log: br.Log.Sub(fmt.Sprintf("Portal/%s", key.ID)), + zlog: br.ZLog.With().Str("portal_id", key.ID).Int("portal_receiver", key.Receiver).Logger(), + + messages: make(chan PortalMessage, br.Config.Bridge.PortalMessageBuffer), + matrixMessages: make(chan PortalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), + + outgoingMessages: make(map[string]id.EventID), + } + go portal.handleMessageLoop() + return portal +} + +func (br *GMBridge) NewPortal(dbPortal *database.Portal) *Portal { + portal := br.newBlankPortal(dbPortal.Key) + portal.Portal = dbPortal + return portal +} + +const recentlyHandledLength = 100 + +type PortalMessage struct { + evt *binary.Message + source *User +} + +type PortalMatrixMessage struct { + evt *event.Event + user *User + receivedAt time.Time +} + +type Portal struct { + *database.Portal + + bridge *GMBridge + // Deprecated: use zerolog + log log.Logger + zlog zerolog.Logger + + roomCreateLock sync.Mutex + encryptLock sync.Mutex + backfillLock sync.Mutex + avatarLock sync.Mutex + + latestEventBackfillLock sync.Mutex + + recentlyHandled [recentlyHandledLength]string + recentlyHandledLock sync.Mutex + recentlyHandledIndex uint8 + + outgoingMessages map[string]id.EventID + outgoingMessagesLock sync.Mutex + + currentlyTyping []id.UserID + currentlyTypingLock sync.Mutex + + messages chan PortalMessage + matrixMessages chan PortalMatrixMessage +} + +var ( + _ bridge.Portal = (*Portal)(nil) + //_ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) + //_ bridge.MembershipHandlingPortal = (*Portal)(nil) + //_ bridge.MetaHandlingPortal = (*Portal)(nil) + //_ bridge.TypingPortal = (*Portal)(nil) +) + +func (portal *Portal) handleMessageLoopItem(msg PortalMessage) { + if len(portal.MXID) == 0 { + return + } + portal.latestEventBackfillLock.Lock() + defer portal.latestEventBackfillLock.Unlock() + switch { + case msg.evt != nil: + portal.handleMessage(msg.source, msg.evt) + //case msg.receipt != nil: + // portal.handleReceipt(msg.receipt, msg.source) + default: + portal.zlog.Warn().Interface("portal_message", msg).Msg("Unexpected PortalMessage with no message") + } +} + +func (portal *Portal) handleMatrixMessageLoopItem(msg PortalMatrixMessage) { + portal.latestEventBackfillLock.Lock() + defer portal.latestEventBackfillLock.Unlock() + evtTS := time.UnixMilli(msg.evt.Timestamp) + timings := messageTimings{ + initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS), + decrypt: msg.evt.Mautrix.DecryptionDuration, + portalQueue: time.Since(msg.receivedAt), + totalReceive: time.Since(evtTS), + } + switch msg.evt.Type { + case event.EventMessage, event.EventSticker: + portal.HandleMatrixMessage(msg.user, msg.evt, timings) + case event.EventReaction: + portal.HandleMatrixReaction(msg.user, msg.evt) + default: + portal.zlog.Warn(). + Str("event_type", msg.evt.Type.Type). + Msg("Unsupported event type in portal message channel") + } +} + +func (portal *Portal) handleMessageLoop() { + for { + select { + case msg := <-portal.messages: + portal.handleMessageLoopItem(msg) + case msg := <-portal.matrixMessages: + portal.handleMatrixMessageLoopItem(msg) + } + } +} + +func (portal *Portal) isOutgoingMessage(evt *binary.Message) id.EventID { + portal.outgoingMessagesLock.Lock() + defer portal.outgoingMessagesLock.Unlock() + evtID, ok := portal.outgoingMessages[evt.TmpId] + if ok { + portal.markHandled(evt, map[string]id.EventID{"": evtID}, true) + return evtID + } + return "" +} + +func (portal *Portal) handleMessage(source *User, evt *binary.Message) { + if len(portal.MXID) == 0 { + portal.zlog.Warn().Msg("handleMessage called even though portal.MXID is empty") + return + } + log := portal.zlog.With(). + Str("message_id", evt.MessageID). + Str("participant_id", evt.ParticipantID). + Str("action", "handleMessage"). + Logger() + if evtID := portal.isOutgoingMessage(evt); evtID != "" { + log.Debug().Str("event_id", evtID.String()).Msg("Got echo for outgoing message") + return + } else if portal.isRecentlyHandled(evt.MessageID) { + log.Debug().Msg("Not handling recent duplicate message") + return + } + existingMsg, err := portal.bridge.DB.Message.GetByID(context.TODO(), portal.Key, evt.MessageID) + if err != nil { + log.Err(err).Msg("Failed to check if message is duplicate") + } else if existingMsg != nil { + log.Debug().Msg("Not handling duplicate message") + return + } + + var intent *appservice.IntentAPI + if evt.GetFrom().GetFromMe() { + intent = source.DoublePuppetIntent + if intent == nil { + log.Debug().Msg("Dropping message from self as double puppeting is not enabled") + return + } + } else { + puppet := source.GetPuppetByID(evt.ParticipantID, "") + if puppet == nil { + log.Debug().Msg("Dropping message from unknown participant") + return + } + intent = puppet.IntentFor(portal) + } + + eventIDs := make(map[string]id.EventID) + var lastEventID id.EventID + ts := time.UnixMicro(evt.Timestamp).UnixMilli() + for _, part := range evt.MessageInfo { + var content event.MessageEventContent + switch data := part.GetData().(type) { + case *binary.MessageInfo_MessageContent: + content = event.MessageEventContent{ + MsgType: event.MsgText, + Body: data.MessageContent.GetContent(), + } + case *binary.MessageInfo_ImageContent: + content = event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("Attachment %s", data.ImageContent.GetImageName()), + } + } + resp, err := portal.sendMessage(intent, event.EventMessage, &content, nil, ts) + if err != nil { + log.Err(err).Msg("Failed to send message") + } else { + eventIDs[part.OrderInternal] = resp.EventID + lastEventID = resp.EventID + } + } + portal.markHandled(evt, eventIDs, true) + portal.sendDeliveryReceipt(lastEventID) + log.Debug().Interface("event_ids", eventIDs).Msg("Handled message") +} + +func (portal *Portal) isRecentlyHandled(id string) bool { + start := portal.recentlyHandledIndex + for i := start; i != start; i = (i - 1) % recentlyHandledLength { + if portal.recentlyHandled[i] == id { + return true + } + } + return false +} + +func (portal *Portal) markHandled(info *binary.Message, mxids map[string]id.EventID, recent bool) *database.Message { + msg := portal.bridge.DB.Message.New() + msg.Chat = portal.Key + msg.ID = info.MessageID + for _, evtID := range mxids { + msg.MXID = evtID + } + msg.Timestamp = time.UnixMicro(info.Timestamp) + msg.Sender = info.ParticipantID + err := msg.Insert(context.TODO()) + if err != nil { + portal.zlog.Err(err).Str("message_id", info.MessageID).Msg("Failed to insert message to database") + } + + if recent { + portal.recentlyHandledLock.Lock() + index := portal.recentlyHandledIndex + portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength + portal.recentlyHandledLock.Unlock() + portal.recentlyHandled[index] = info.MessageID + } + return msg +} + +func (portal *Portal) SyncParticipants(source *User, metadata *binary.Conversation) (userIDs []id.UserID) { + var firstParticipant *binary.Participant + var manyParticipants bool + for _, participant := range metadata.Participants { + if participant.IsMe { + continue + } else if participant.Id.Number == "" { + portal.zlog.Warn().Interface("participant", participant).Msg("No number found in non-self participant entry") + continue + } + if firstParticipant == nil { + firstParticipant = participant + } else { + manyParticipants = true + } + portal.zlog.Debug().Interface("participant", participant).Msg("Syncing participant") + puppet := source.GetPuppetByID(participant.Id.ParticipantID, participant.Id.Number) + userIDs = append(userIDs, puppet.MXID) + puppet.Sync(source, participant) + if portal.MXID != "" { + err := puppet.IntentFor(portal).EnsureJoined(portal.MXID) + if err != nil { + portal.zlog.Err(err). + Str("user_id", puppet.MXID.String()). + Msg("Failed to ensure ghost is joined to portal") + } + } + } + if !metadata.IsGroupChat && !manyParticipants { + portal.zlog.Info(). + Str("old_other_user_id", portal.OtherUserID). + Str("new_other_user_id", firstParticipant.Id.ParticipantID). + Msg("Found other user ID in DM") + portal.OtherUserID = firstParticipant.Id.ParticipantID + } + return userIDs +} + +func (portal *Portal) UpdateName(name string, updateInfo bool) bool { + if portal.Name != name || (!portal.NameSet && len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata()) { + portal.zlog.Debug().Str("old_name", portal.Name).Str("new_name", name).Msg("Updating name") + portal.Name = name + portal.NameSet = false + if updateInfo { + defer func() { + err := portal.Update(context.TODO()) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after updating name") + } + }() + } + + if len(portal.MXID) > 0 && !portal.shouldSetDMRoomMetadata() { + portal.UpdateBridgeInfo() + } else if len(portal.MXID) > 0 { + intent := portal.MainIntent() + _, err := intent.SetRoomName(portal.MXID, name) + if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { + _, err = portal.MainIntent().SetRoomName(portal.MXID, name) + } + if err == nil { + portal.NameSet = true + if updateInfo { + portal.UpdateBridgeInfo() + } + return true + } else { + portal.zlog.Warn().Err(err).Msg("Failed to set room name") + } + } + } + return false +} + +func (portal *Portal) UpdateMetadata(user *User, info *binary.Conversation) []id.UserID { + participants := portal.SyncParticipants(user, info) + if portal.IsPrivateChat() { + return participants + } + update := false + if portal.MXID != "" { + update = portal.addToPersonalSpace(user) || update + } + update = portal.UpdateName(info.Name, false) || update + // TODO avatar + if update { + err := portal.Update(context.TODO()) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after updating metadata") + } + if portal.MXID != "" { + portal.UpdateBridgeInfo() + } + } + return participants +} + +func (portal *Portal) ensureUserInvited(user *User) bool { + return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) +} + +func (portal *Portal) UpdateMatrixRoom(user *User, groupInfo *binary.Conversation) bool { + if len(portal.MXID) == 0 { + return false + } + + portal.ensureUserInvited(user) + portal.UpdateMetadata(user, groupInfo) + return true +} + +func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { + anyone := 0 + nope := 99 + invite := 50 + return &event.PowerLevelsEventContent{ + UsersDefault: anyone, + EventsDefault: anyone, + RedactPtr: &anyone, + StateDefaultPtr: &nope, + BanPtr: &nope, + InvitePtr: &invite, + Users: map[id.UserID]int{ + portal.MainIntent().UserID: 100, + }, + Events: map[string]int{ + event.StateRoomName.Type: anyone, + event.StateRoomAvatar.Type: anyone, + event.EventReaction.Type: anyone, // TODO only allow reactions in RCS rooms + }, + } +} + +func (portal *Portal) getBridgeInfoStateKey() string { + return fmt.Sprintf("fi.mau.gmessages://gmessages/%s", portal.ID) +} + +func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) { + return portal.getBridgeInfoStateKey(), event.BridgeEventContent{ + BridgeBot: portal.bridge.Bot.UserID, + Creator: portal.MainIntent().UserID, + Protocol: event.BridgeInfoSection{ + ID: "gmessages", + DisplayName: "Google Messages", + AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), + ExternalURL: "https://messages.google.com/", + }, + Channel: event.BridgeInfoSection{ + ID: portal.ID, + DisplayName: portal.Name, + AvatarURL: portal.AvatarMXC.CUString(), + }, + } +} + +func (portal *Portal) UpdateBridgeInfo() { + if len(portal.MXID) == 0 { + portal.zlog.Debug().Msg("Not updating bridge info: no Matrix room created") + return + } + portal.zlog.Debug().Msg("Updating bridge info...") + stateKey, content := portal.getBridgeInfo() + _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content) + if err != nil { + portal.zlog.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(portal.MXID, event.StateHalfShotBridge, stateKey, content) + if err != nil { + portal.zlog.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge") + } +} + +func (portal *Portal) shouldSetDMRoomMetadata() bool { + return !portal.IsPrivateChat() || + portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || + (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") +} + +func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { + evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} + if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { + evt.RotationPeriodMillis = rot.Milliseconds + evt.RotationPeriodMessages = rot.Messages + } + return +} + +func (portal *Portal) CreateMatrixRoom(user *User, conv *binary.Conversation) error { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + if len(portal.MXID) > 0 { + return nil + } + + members := portal.UpdateMetadata(user, conv) + + if portal.IsPrivateChat() && portal.GetDMPuppet() == nil { + portal.zlog.Error().Msg("Didn't find ghost of other user in DM :(") + return fmt.Errorf("ghost not found") + } + + intent := portal.MainIntent() + if err := intent.EnsureRegistered(); err != nil { + return err + } + + portal.zlog.Info().Msg("Creating Matrix room") + + bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() + + initialState := []*event.Event{{ + Type: event.StatePowerLevels, + Content: event.Content{ + Parsed: portal.GetBasePowerLevels(), + }, + }, { + Type: event.StateBridge, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }, { + // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + Type: event.StateHalfShotBridge, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }} + var invite []id.UserID + if portal.bridge.Config.Bridge.Encryption.Default { + initialState = append(initialState, &event.Event{ + Type: event.StateEncryption, + Content: event.Content{ + Parsed: portal.GetEncryptionEventContent(), + }, + }) + portal.Encrypted = true + if portal.IsPrivateChat() { + invite = append(invite, portal.bridge.Bot.UserID) + } + } + if !portal.AvatarMXC.IsEmpty() && portal.shouldSetDMRoomMetadata() { + initialState = append(initialState, &event.Event{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: event.RoomAvatarEventContent{URL: portal.AvatarMXC}, + }, + }) + portal.AvatarSet = true + } else { + portal.AvatarSet = false + } + + creationContent := make(map[string]interface{}) + if !portal.bridge.Config.Bridge.FederateRooms { + creationContent["m.federate"] = false + } + autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) + if autoJoinInvites { + portal.zlog.Debug().Msg("Hungryserv mode: adding all group members in create request") + invite = append(invite, members...) + invite = append(invite, user.MXID) + } + req := &mautrix.ReqCreateRoom{ + Visibility: "private", + Name: portal.Name, + Invite: invite, + Preset: "private_chat", + IsDirect: portal.IsPrivateChat(), + InitialState: initialState, + CreationContent: creationContent, + + BeeperAutoJoinInvites: autoJoinInvites, + } + if !portal.shouldSetDMRoomMetadata() { + req.Name = "" + } + resp, err := intent.CreateRoom(req) + if err != nil { + return err + } + portal.zlog.Info().Str("room_id", resp.RoomID.String()).Msg("Matrix room created") + portal.InSpace = false + portal.NameSet = len(req.Name) > 0 + portal.MXID = resp.RoomID + portal.bridge.portalsLock.Lock() + portal.bridge.portalsByMXID[portal.MXID] = portal + portal.bridge.portalsLock.Unlock() + err = portal.Update(context.TODO()) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after creating room") + } + + // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here. + inviteMembership := event.MembershipInvite + if autoJoinInvites { + inviteMembership = event.MembershipJoin + } + for _, userID := range invite { + portal.bridge.StateStore.SetMembership(portal.MXID, userID, inviteMembership) + } + + if !autoJoinInvites { + if !portal.IsPrivateChat() { + portal.SyncParticipants(user, conv) + } else { + if portal.bridge.Config.Bridge.Encryption.Default { + err = portal.bridge.Bot.EnsureJoined(portal.MXID) + if err != nil { + portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err) + } + } + + user.UpdateDirectChats(map[id.UserID][]id.RoomID{portal.GetDMPuppet().MXID: {portal.MXID}}) + } + portal.ensureUserInvited(user) + } + user.syncChatDoublePuppetDetails(portal, conv, true) + + go portal.addToPersonalSpace(user) + return nil +} + +func (portal *Portal) addToPersonalSpace(user *User) bool { + spaceID := user.GetSpaceRoom() + if len(spaceID) == 0 || portal.InSpace { + return false + } + _, err := portal.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ + Via: []string{portal.bridge.Config.Homeserver.Domain}, + }) + if err != nil { + portal.zlog.Err(err).Str("space_id", spaceID.String()).Msg("Failed to add room to user's personal filtering space") + portal.InSpace = false + } else { + portal.zlog.Debug().Str("space_id", spaceID.String()).Msg("Added room to user's personal filtering space") + portal.InSpace = true + } + return true +} + +func (portal *Portal) IsPrivateChat() bool { + return portal.OtherUserID != "" +} + +func (portal *Portal) GetDMPuppet() *Puppet { + if portal.IsPrivateChat() { + puppet := portal.bridge.GetPuppetByKey(database.Key{Receiver: portal.Receiver, ID: portal.OtherUserID}, "") + return puppet + } + return nil +} + +func (portal *Portal) MainIntent() *appservice.IntentAPI { + if puppet := portal.GetDMPuppet(); puppet != nil { + return puppet.DefaultIntent() + } + return portal.bridge.Bot +} + +func (portal *Portal) sendMainIntentMessage(content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { + return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) +} + +func (portal *Portal) encrypt(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(portal.MXID, eventType, content) + if err != nil { + return eventType, fmt.Errorf("failed to encrypt event: %w", err) + } + return event.EventEncrypted, nil +} + +func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { + wrappedContent := event.Content{Parsed: content, Raw: extraContent} + var err error + eventType, err = portal.encrypt(intent, &wrappedContent, eventType) + if err != nil { + return nil, err + } + + _, _ = intent.UserTyping(portal.MXID, false, 0) + if timestamp == 0 { + return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) + } else { + return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) + } +} + +func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) { + if !portal.Encrypted { + return mimeType, nil + } + + file := &event.EncryptedFileInfo{ + EncryptedFile: *attachment.NewEncryptedFile(), + URL: "", + } + file.EncryptInPlace(data) + return "application/octet-stream", file +} + +func (portal *Portal) uploadMedia(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error { + uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType) + + req := mautrix.ReqUploadMedia{ + ContentBytes: data, + ContentType: uploadMimeType, + } + var mxc id.ContentURI + if portal.bridge.Config.Homeserver.AsyncMedia { + uploaded, err := intent.UploadAsync(req) + if err != nil { + return err + } + mxc = uploaded.ContentURI + } else { + uploaded, err := intent.UploadMedia(req) + if err != nil { + return err + } + mxc = uploaded.ContentURI + } + + if file != nil { + file.URL = mxc.CUString() + content.File = file + } else { + content.URL = mxc.CUString() + } + + content.Info.Size = len(data) + if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") { + cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) + content.Info.Width, content.Info.Height = cfg.Width, cfg.Height + } + + // This is a hack for bad clients like Element iOS that require a thumbnail (https://github.com/vector-im/element-ios/issues/4004) + if strings.HasPrefix(content.Info.MimeType, "image/") && content.Info.ThumbnailInfo == nil { + infoCopy := *content.Info + content.Info.ThumbnailInfo = &infoCopy + if content.File != nil { + content.Info.ThumbnailFile = file + } else { + content.Info.ThumbnailURL = content.URL + } + } + return nil +} + +func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event, timings messageTimings) { + ms := metricSender{portal: portal, timings: &timings} + + log := portal.zlog.With().Str("event_id", evt.ID.String()).Logger() + log.Debug().Dur("age", timings.totalReceive).Msg("Handling Matrix message") + + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok { + return + } + + txnID := util.GenerateTmpId() + portal.outgoingMessagesLock.Lock() + portal.outgoingMessages[txnID] = evt.ID + portal.outgoingMessagesLock.Unlock() + switch content.MsgType { + case event.MsgText, event.MsgEmote, event.MsgNotice: + text := content.Body + if content.MsgType == event.MsgEmote { + text = "/me " + text + } + _, err := sender.Client.Conversations.SendMessage( + sender.Client.NewMessageBuilder(). + SetConversationID(portal.ID). + SetSelfParticipantID(portal.SelfUserID). + SetContent(text). + SetTmpID(txnID), "", + ) + if err != nil { + go ms.sendMessageMetrics(evt, err, "Error sending", true) + } else { + go ms.sendMessageMetrics(evt, nil, "", true) + } + default: + go ms.sendMessageMetrics(evt, fmt.Errorf("unsupported msgtype"), "Ignoring", true) + } +} + +func (portal *Portal) HandleMatrixReaction(sender *User, evt *event.Event) { + +} + +func (portal *Portal) Delete() { + err := portal.Portal.Delete(context.TODO()) + if err != nil { + portal.zlog.Err(err).Msg("Failed to delete portal from database") + } + portal.bridge.portalsLock.Lock() + delete(portal.bridge.portalsByKey, portal.Key) + if len(portal.MXID) > 0 { + delete(portal.bridge.portalsByMXID, portal.MXID) + } + portal.bridge.portalsLock.Unlock() +} + +func (portal *Portal) GetMatrixUsers() ([]id.UserID, error) { + members, err := portal.MainIntent().JoinedMembers(portal.MXID) + if err != nil { + return nil, fmt.Errorf("failed to get member list: %w", err) + } + var users []id.UserID + for userID := range members.Joined { + _, isPuppet := portal.bridge.ParsePuppetMXID(userID) + if !isPuppet && userID != portal.bridge.Bot.UserID { + users = append(users, userID) + } + } + return users, nil +} + +func (portal *Portal) CleanupIfEmpty() { + users, err := portal.GetMatrixUsers() + if err != nil { + portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err) + return + } + + if len(users) == 0 { + portal.log.Infoln("Room seems to be empty, cleaning up...") + portal.Delete() + portal.Cleanup(false) + } +} + +func (portal *Portal) Cleanup(puppetsOnly bool) { + if len(portal.MXID) == 0 { + return + } + intent := portal.MainIntent() + if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { + err := intent.BeeperDeleteRoom(portal.MXID) + if err != nil && !errors.Is(err, mautrix.MNotFound) { + portal.zlog.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint") + } + return + } + members, err := intent.JoinedMembers(portal.MXID) + if err != nil { + portal.log.Errorln("Failed to get portal members for cleanup:", err) + return + } + for member := range members.Joined { + if member == intent.UserID { + continue + } + puppet := portal.bridge.GetPuppetByMXID(member) + if puppet != nil { + _, err = puppet.DefaultIntent().LeaveRoom(portal.MXID) + if err != nil { + portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err) + } + } else if !puppetsOnly { + _, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) + if err != nil { + portal.log.Errorln("Error kicking user while cleaning up portal:", err) + } + } + } + _, err = intent.LeaveRoom(portal.MXID) + if err != nil { + portal.log.Errorln("Error leaving with main intent while cleaning up portal:", err) + } +} diff --git a/puppet.go b/puppet.go new file mode 100644 index 0000000..3e5ddc5 --- /dev/null +++ b/puppet.go @@ -0,0 +1,280 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "regexp" + "strconv" + + "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-gmessages/database" + "go.mau.fi/mautrix-gmessages/libgm/binary" +) + +var userIDRegex *regexp.Regexp + +func (br *GMBridge) ParsePuppetMXID(mxid id.UserID) (key database.Key, ok bool) { + if userIDRegex == nil { + userIDRegex = br.Config.MakeUserIDRegex(`([0-9]+)\.([0-9]+)`) + } + match := userIDRegex.FindStringSubmatch(string(mxid)) + if len(match) == 3 { + var err error + key.Receiver, err = strconv.Atoi(match[1]) + ok = err == nil + key.ID = match[2] + } + return +} + +func (br *GMBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { + key, ok := br.ParsePuppetMXID(mxid) + if !ok { + return nil + } + + return br.GetPuppetByKey(key, "") +} + +func (br *GMBridge) GetPuppetByKey(key database.Key, phone string) *Puppet { + br.puppetsLock.Lock() + defer br.puppetsLock.Unlock() + puppet, ok := br.puppetsByKey[key] + if !ok { + dbPuppet, err := br.DB.Puppet.Get(context.TODO(), key) + if err != nil { + br.ZLog.Err(err).Object("puppet_key", key).Msg("Failed to get puppet from database") + return nil + } + if phone == "" { + return nil + } + if dbPuppet == nil { + dbPuppet = br.DB.Puppet.New() + dbPuppet.Key = key + dbPuppet.Phone = phone + err = dbPuppet.Insert(context.TODO()) + if err != nil { + br.ZLog.Err(err).Object("puppet_key", key).Msg("Failed to insert puppet into database") + return nil + } + } + puppet = br.NewPuppet(dbPuppet) + br.puppetsByKey[puppet.Key] = puppet + } + return puppet +} + +func (br *GMBridge) IsGhost(id id.UserID) bool { + _, ok := br.ParsePuppetMXID(id) + return ok +} + +func (br *GMBridge) GetIGhost(id id.UserID) bridge.Ghost { + p := br.GetPuppetByMXID(id) + if p == nil { + return nil + } + return p +} + +func (puppet *Puppet) GetMXID() id.UserID { + return puppet.MXID +} + +func (br *GMBridge) GetAllPuppets() []*Puppet { + return br.loadManyPuppets(br.DB.Puppet.GetAll) +} + +func (br *GMBridge) loadManyPuppets(query func(ctx context.Context) ([]*database.Puppet, error)) []*Puppet { + br.puppetsLock.Lock() + defer br.puppetsLock.Unlock() + dbPuppets, err := query(context.TODO()) + if err != nil { + br.ZLog.Err(err).Msg("Failed to load all puppets from database") + return []*Puppet{} + } + output := make([]*Puppet, len(dbPuppets)) + for index, dbPuppet := range dbPuppets { + if dbPuppet == nil { + continue + } + puppet, ok := br.puppetsByKey[dbPuppet.Key] + if !ok { + puppet = br.NewPuppet(dbPuppet) + br.puppetsByKey[puppet.Key] = puppet + } + output[index] = puppet + } + return output +} + +func (br *GMBridge) FormatPuppetMXID(key database.Key) id.UserID { + return id.NewUserID( + br.Config.Bridge.FormatUsername(key.String()), + br.Config.Homeserver.Domain) +} + +func (br *GMBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { + return &Puppet{ + Puppet: dbPuppet, + bridge: br, + log: br.ZLog.With().Str("phone", dbPuppet.Phone).Int("puppet_receiver", dbPuppet.Receiver).Logger(), + MXID: br.FormatPuppetMXID(dbPuppet.Key), + } +} + +type Puppet struct { + *database.Puppet + bridge *GMBridge + log zerolog.Logger + MXID id.UserID +} + +var _ bridge.GhostWithProfile = (*Puppet)(nil) + +func (puppet *Puppet) GetDisplayname() string { + return puppet.Name +} + +func (puppet *Puppet) GetAvatarURL() id.ContentURI { + return puppet.AvatarMXC +} + +func (puppet *Puppet) SwitchCustomMXID(_ string, _ id.UserID) error { + return fmt.Errorf("puppets don't support custom MXIDs here") +} + +func (puppet *Puppet) IntentFor(_ *Portal) *appservice.IntentAPI { + return puppet.DefaultIntent() +} + +func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { + return nil +} + +func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { + return puppet.bridge.AS.Intent(puppet.MXID) +} + +func (puppet *Puppet) UpdateAvatar(source *User, avatarID string) bool { + if puppet.AvatarID == avatarID && puppet.AvatarSet { + return false + } + puppet.AvatarID = avatarID + puppet.AvatarMXC = id.ContentURI{} + puppet.AvatarSet = false + // TODO bridge avatar + if puppet.AvatarMXC.IsEmpty() { + return false + } + err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarMXC) + if err != nil { + puppet.log.Warn().Err(err).Msg("Failed to set avatar") + } else { + puppet.AvatarSet = true + } + go puppet.updatePortalAvatar() + return true +} + +func (puppet *Puppet) UpdateName(formattedPhone, fullName, firstName string) bool { + newName := puppet.bridge.Config.Bridge.FormatDisplayname(formattedPhone, fullName, firstName) + if puppet.Name != newName || !puppet.NameSet { + oldName := puppet.Name + puppet.Name = newName + puppet.NameSet = false + err := puppet.DefaultIntent().SetDisplayName(newName) + if err == nil { + puppet.log.Debug().Str("old_name", oldName).Str("new_name", newName).Msg("Updated displayname") + puppet.NameSet = true + go puppet.updatePortalName() + } else { + puppet.log.Warn().Err(err).Msg("Failed to set displayname") + } + return true + } + return false +} + +func (puppet *Puppet) UpdateContactInfo() bool { + if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) { + return false + } + + if puppet.ContactInfoSet { + return false + } + + contactInfo := map[string]any{ + "com.beeper.bridge.identifiers": []string{ + fmt.Sprintf("tel:+%s", puppet.Phone), + }, + "com.beeper.bridge.remote_id": puppet.Key.String(), + "com.beeper.bridge.service": "gmessages", + "com.beeper.bridge.network": "gmessages", + } + err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo) + if err != nil { + puppet.log.Warn().Err(err).Msg("Failed to store custom contact info in profile") + return false + } else { + puppet.ContactInfoSet = true + return true + } +} + +func (puppet *Puppet) updatePortalAvatar() { + // TODO implement +} + +func (puppet *Puppet) updatePortalName() { + // TODO implement +} + +func (puppet *Puppet) Sync(source *User, contact *binary.Participant) { + err := puppet.DefaultIntent().EnsureRegistered() + if err != nil { + puppet.log.Err(err).Msg("Failed to ensure registered") + } + + puppet.log.Debug().Msg("Syncing info") + + update := false + if contact != nil { + if contact.Id.Number != "" && puppet.Phone != contact.Id.Number { + puppet.Phone = contact.Id.Number + update = true + } + update = puppet.UpdateName(contact.GetFormattedNumber(), contact.GetFullName(), contact.GetFirstName()) || update + update = puppet.UpdateAvatar(source, contact.GetAvatarID()) || update + } + update = puppet.UpdateContactInfo() || update + if update { + err = puppet.Update(context.TODO()) + if err != nil { + puppet.log.Err(err).Msg("Failed to save puppet to database after sync") + } + } +} diff --git a/segment.go b/segment.go new file mode 100644 index 0000000..6bba669 --- /dev/null +++ b/segment.go @@ -0,0 +1,97 @@ +// mautrix-gmessages - A Matrix-Google Messages puppeting bridge. +// Copyright (C) 2023 Tulir Asokan, Sumner Evans +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" +) + +const SegmentURL = "https://api.segment.io/v1/track" + +type SegmentClient struct { + key string + userID string + log zerolog.Logger + client http.Client +} + +var Segment SegmentClient + +func (sc *SegmentClient) trackSync(userID id.UserID, event string, properties map[string]interface{}) error { + var buf bytes.Buffer + var segmentUserID string + if Segment.userID != "" { + segmentUserID = Segment.userID + } else { + segmentUserID = userID.String() + } + err := json.NewEncoder(&buf).Encode(map[string]interface{}{ + "userId": segmentUserID, + "event": event, + "properties": properties, + }) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", SegmentURL, &buf) + if err != nil { + return err + } + req.SetBasicAuth(sc.key, "") + resp, err := sc.client.Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + return nil +} + +func (sc *SegmentClient) IsEnabled() bool { + return len(sc.key) > 0 +} + +func (sc *SegmentClient) Track(userID id.UserID, event string, properties ...map[string]interface{}) { + if !sc.IsEnabled() { + return + } else if len(properties) > 1 { + panic("Track should be called with at most one property map") + } + + go func() { + props := map[string]interface{}{} + if len(properties) > 0 { + props = properties[0] + } + props["bridge"] = "gmessages" + err := sc.trackSync(userID, event, props) + if err != nil { + sc.log.Err(err).Str("event", event).Msg("Error tracking event") + } else { + sc.log.Debug().Str("event", event).Msg("Tracked event") + } + }() +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..096d8d1 --- /dev/null +++ b/user.go @@ -0,0 +1,703 @@ +// mautrix-gmessages - A Matrix-Google Messages 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" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/maulogger/v2" + "maunium.net/go/maulogger/v2/maulogadapt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/bridge" + "maunium.net/go/mautrix/bridge/bridgeconfig" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" + + "go.mau.fi/mautrix-gmessages/database" + "go.mau.fi/mautrix-gmessages/libgm" + "go.mau.fi/mautrix-gmessages/libgm/binary" + "go.mau.fi/mautrix-gmessages/libgm/crypto" + "go.mau.fi/mautrix-gmessages/libgm/events" +) + +type User struct { + *database.User + Client *libgm.Client + + bridge *GMBridge + zlog zerolog.Logger + // Deprecated + log maulogger.Logger + + Admin bool + Whitelisted bool + PermissionLevel bridgeconfig.PermissionLevel + + mgmtCreateLock sync.Mutex + spaceCreateLock sync.Mutex + connLock sync.Mutex + + BridgeState *bridge.BridgeStateQueue + + spaceMembershipChecked bool + + DoublePuppetIntent *appservice.IntentAPI +} + +func (br *GMBridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { + _, isPuppet := br.ParsePuppetMXID(userID) + if isPuppet || userID == br.Bot.UserID { + return nil + } + br.usersLock.Lock() + defer br.usersLock.Unlock() + user, ok := br.usersByMXID[userID] + if !ok { + userIDPtr := &userID + if onlyIfExists { + userIDPtr = nil + } + dbUser, err := br.DB.User.GetByMXID(context.TODO(), userID) + if err != nil { + br.ZLog.Err(err). + Str("user_id", userID.String()). + Msg("Failed to load user from database") + return nil + } + return br.loadDBUser(dbUser, userIDPtr) + } + return user +} + +func (br *GMBridge) GetUserByMXID(userID id.UserID) *User { + return br.getUserByMXID(userID, false) +} + +func (br *GMBridge) GetIUser(userID id.UserID, create bool) bridge.User { + u := br.getUserByMXID(userID, !create) + if u == nil { + return nil + } + return u +} + +func (user *User) GetPuppetByID(id, phone string) *Puppet { + return user.bridge.GetPuppetByKey(database.Key{Receiver: user.RowID, ID: id}, phone) +} + +func (user *User) GetPortalByID(id string) *Portal { + return user.bridge.GetPortalByKey(database.Key{Receiver: user.RowID, ID: id}) +} + +func (user *User) GetIDoublePuppet() bridge.DoublePuppet { + return user +} + +func (user *User) GetIGhost() bridge.Ghost { + return nil +} + +func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { + return user.PermissionLevel +} + +func (user *User) GetManagementRoomID() id.RoomID { + return user.ManagementRoom +} + +func (user *User) GetMXID() id.UserID { + return user.MXID +} + +func (user *User) GetCommandState() map[string]interface{} { + return nil +} + +func (br *GMBridge) GetUserByMXIDIfExists(userID id.UserID) *User { + return br.getUserByMXID(userID, true) +} + +func (br *GMBridge) GetUserByPhone(phone string) *User { + br.usersLock.Lock() + defer br.usersLock.Unlock() + user, ok := br.usersByPhone[phone] + if !ok { + dbUser, err := br.DB.User.GetByPhone(context.TODO(), phone) + if err != nil { + br.ZLog.Err(err). + Str("phone", phone). + Msg("Failed to load user from database") + } + return br.loadDBUser(dbUser, nil) + } + return user +} + +func (user *User) addToPhoneMap() { + user.bridge.usersLock.Lock() + user.bridge.usersByPhone[user.Phone] = user + user.bridge.usersLock.Unlock() +} + +func (user *User) removeFromPhoneMap(state status.BridgeState) { + user.bridge.usersLock.Lock() + phoneUser, ok := user.bridge.usersByPhone[user.Phone] + if ok && user == phoneUser { + delete(user.bridge.usersByPhone, user.Phone) + } + user.bridge.usersLock.Unlock() + user.BridgeState.Send(state) +} + +func (br *GMBridge) GetAllUsersWithSession() []*User { + return br.loadManyUsers(br.DB.User.GetAllWithSession) +} + +func (br *GMBridge) GetAllUsersWithDoublePuppet() []*User { + return br.loadManyUsers(br.DB.User.GetAllWithDoublePuppet) +} + +func (br *GMBridge) loadManyUsers(query func(ctx context.Context) ([]*database.User, error)) []*User { + br.usersLock.Lock() + defer br.usersLock.Unlock() + dbUsers, err := query(context.TODO()) + if err != nil { + br.ZLog.Err(err).Msg("Failed to all load users from database") + return []*User{} + } + output := make([]*User, len(dbUsers)) + for index, dbUser := range dbUsers { + user, ok := br.usersByMXID[dbUser.MXID] + if !ok { + user = br.loadDBUser(dbUser, nil) + } + output[index] = user + } + return output +} + +func (br *GMBridge) loadDBUser(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(context.TODO()) + if err != nil { + br.ZLog.Err(err). + Str("user_id", mxid.String()). + Msg("Failed to insert user to database") + return nil + } + } + user := br.NewUser(dbUser) + br.usersByMXID[user.MXID] = user + if user.Session != nil && user.Phone != "" { + br.usersByPhone[user.Phone] = user + } else { + user.Session = nil + user.Phone = "" + } + if len(user.ManagementRoom) > 0 { + br.managementRooms[user.ManagementRoom] = user + } + return user +} + +func (br *GMBridge) NewUser(dbUser *database.User) *User { + user := &User{ + User: dbUser, + bridge: br, + zlog: br.ZLog.With().Str("user_id", dbUser.MXID.String()).Logger(), + } + user.log = maulogadapt.ZeroAsMau(&user.zlog) + + user.PermissionLevel = user.bridge.Config.Bridge.Permissions.Get(user.MXID) + user.Whitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelUser + user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin + user.BridgeState = br.NewBridgeStateQueue(user) + return user +} + +func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { + extraContent := make(map[string]any) + if isDirect { + extraContent["is_direct"] = true + } + if user.DoublePuppetIntent != nil { + extraContent["fi.mau.will_auto_accept"] = true + } + _, err := intent.InviteUser(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") { + user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin) + ok = true + return + } else if err != nil { + user.zlog.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to invite user to room") + } else { + ok = true + } + + if user.DoublePuppetIntent != nil { + err = user.DoublePuppetIntent.EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) + if err != nil { + user.zlog.Warn().Err(err).Str("room_id", roomID.String()).Msg("Failed to auto-join room") + ok = false + } else { + ok = true + } + } + return +} + +func (user *User) GetSpaceRoom() 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(&mautrix.ReqCreateRoom{ + Visibility: "private", + Name: "Google Messages", + Topic: "Your Google Messages bridged chats", + InitialState: []*event.Event{{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{ + URL: user.bridge.Config.AppService.Bot.ParsedAvatar, + }, + }, + }}, + CreationContent: map[string]interface{}{ + "type": event.RoomTypeSpace, + }, + PowerLevelOverride: &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + user.bridge.Bot.UserID: 9001, + user.MXID: 50, + }, + }, + }) + + if err != nil { + user.zlog.Err(err).Msg("Failed to auto-create space room") + } else { + user.SpaceRoom = resp.RoomID + err = user.Update(context.TODO()) + if err != nil { + user.zlog.Err(err).Msg("Failed to update database after creating space room") + } + user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false) + } + } else if !user.spaceMembershipChecked && !user.bridge.StateStore.IsInRoom(user.SpaceRoom, user.MXID) { + user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false) + } + user.spaceMembershipChecked = true + + return user.SpaceRoom +} + +func (user *User) GetManagementRoom() id.RoomID { + if len(user.ManagementRoom) == 0 { + user.mgmtCreateLock.Lock() + defer user.mgmtCreateLock.Unlock() + if len(user.ManagementRoom) > 0 { + return user.ManagementRoom + } + creationContent := make(map[string]interface{}) + if !user.bridge.Config.Bridge.FederateRooms { + creationContent["m.federate"] = false + } + resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ + Topic: "Google Messages bridge notices", + IsDirect: true, + CreationContent: creationContent, + }) + if err != nil { + user.zlog.Err(err).Msg("Failed to auto-create management room") + } else { + user.SetManagementRoom(resp.RoomID) + } + } + return user.ManagementRoom +} + +func (user *User) SetManagementRoom(roomID id.RoomID) { + log := user.zlog.With(). + Str("management_room_id", roomID.String()). + Str("action", "SetManagementRoom"). + Logger() + existingUser, ok := user.bridge.managementRooms[roomID] + if ok { + existingUser.ManagementRoom = "" + err := existingUser.Update(context.TODO()) + if err != nil { + log.Err(err). + Str("prev_user_id", existingUser.MXID.String()). + Msg("Failed to clear management room from previous user") + } + } + + user.ManagementRoom = roomID + user.bridge.managementRooms[user.ManagementRoom] = user + err := user.Update(context.TODO()) + if err != nil { + log.Err(err).Msg("Failed to update database with management room ID") + } +} + +var ErrAlreadyLoggedIn = errors.New("already logged in") + +func (user *User) createClient() { + var devicePair *libgm.DevicePair + var cryptor *crypto.Cryptor + if user.Session != nil && user.Session.WebAuthKey != nil { + devicePair = &libgm.DevicePair{ + Mobile: user.Session.PhoneInfo, + Browser: user.Session.BrowserInfo, + } + cryptor = &crypto.Cryptor{ + AESCTR256Key: user.Session.AESKey, + SHA256Key: user.Session.HMACKey, + } + } else { + cryptor = crypto.NewCryptor(nil, nil) + user.Session = &database.Session{ + AESKey: cryptor.AESCTR256Key, + HMACKey: cryptor.SHA256Key, + } + } + user.Client = libgm.NewClient(devicePair, cryptor, user.zlog.With().Str("component", "libgm").Logger(), nil) + user.Client.SetEventHandler(user.HandleEvent) +} + +func (user *User) Login(ctx context.Context) (<-chan string, error) { + user.connLock.Lock() + defer user.connLock.Unlock() + if user.Session != nil { + return nil, ErrAlreadyLoggedIn + } else if user.Client != nil { + user.unlockedDeleteConnection() + } + user.createClient() + pairer, err := user.Client.NewPairer(nil, 20) + if err != nil { + return nil, fmt.Errorf("failed to initialize pairer: %w", err) + } + resp, err := pairer.RegisterPhoneRelay() + if err != nil { + return nil, fmt.Errorf("failed to register phone relay: %w", err) + } + err = user.Client.Connect(resp.Field5.RpcKey) + if err != nil { + return nil, fmt.Errorf("failed to connect to Google Messages: %w", err) + } + return nil, nil +} + +func (user *User) Connect() bool { + user.connLock.Lock() + defer user.connLock.Unlock() + if user.Client != nil { + return true + } else if user.Session == nil { + return false + } + user.zlog.Debug().Msg("Connecting to Google Messages") + user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting, Error: WAConnecting}) + err := user.Client.Connect(user.Session.WebAuthKey) + if err != nil { + user.zlog.Err(err).Msg("Error connecting to Google Messages") + user.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: WAConnectionFailed, + Info: map[string]interface{}{ + "go_error": err.Error(), + }, + }) + return false + } + return true +} + +func (user *User) unlockedDeleteConnection() { + if user.Client == nil { + return + } + user.Client.Disconnect() + user.Client.SetEventHandler(nil) + user.Client = nil +} + +func (user *User) DeleteConnection() { + user.connLock.Lock() + defer user.connLock.Unlock() + user.unlockedDeleteConnection() +} + +func (user *User) HasSession() bool { + return user.Session != nil +} + +func (user *User) DeleteSession() { + user.Session = nil + user.Phone = "" + err := user.Update(context.TODO()) + if err != nil { + user.zlog.Err(err).Msg("Failed to delete session from database") + } +} + +func (user *User) IsConnected() bool { + return user.Client != nil && user.Client.IsConnected() +} + +func (user *User) IsLoggedIn() bool { + return user.IsConnected() && user.Client.IsLoggedIn() +} + +func (user *User) tryAutomaticDoublePuppeting() { + if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) || user.DoublePuppetIntent != nil { + return + } + + if err := user.loginWithSharedSecret(); err != nil { + user.zlog.Warn().Err(err).Msg("Failed to login with shared secret for double puppeting") + } else if err = user.startCustomMXID(false); err != nil { + user.zlog.Warn().Err(err).Msg("Failed to start double puppet after logging in with shared secret") + } else { + user.zlog.Info().Msg("Successfully automatically enabled double puppet") + } +} + +func (user *User) sendMarkdownBridgeAlert(formatString string, args ...interface{}) { + if user.bridge.Config.Bridge.DisableBridgeAlerts { + return + } + notice := fmt.Sprintf(formatString, args...) + content := format.RenderMarkdown(notice, true, false) + _, err := user.bridge.Bot.SendMessageEvent(user.GetManagementRoom(), event.EventMessage, content) + if err != nil { + user.zlog.Warn().Err(err).Str("notice", notice).Msg("Failed to send bridge alert") + } +} + +func (user *User) HandleEvent(event interface{}) { + switch v := event.(type) { + case *events.QR: + // These should be here + user.zlog.Info().Msg(v.URL) + case *events.PairSuccessful: + user.Phone = v.PairDeviceData.Mobile.RegistrationID + user.Session.PhoneInfo = v.PairDeviceData.Mobile + user.Session.BrowserInfo = v.PairDeviceData.Browser + user.Session.WebAuthKey = v.PairDeviceData.WebAuthKeyData.GetWebAuthKey() + user.addToPhoneMap() + err := user.Update(context.TODO()) + if err != nil { + user.zlog.Err(err).Msg("Failed to update session in database") + } + case *binary.Event_ConversationEvent: + portal := user.GetPortalByID(v.ConversationEvent.GetData().GetConversationID()) + if portal.MXID != "" { + portal.UpdateMetadata(user, v.ConversationEvent.GetData()) + } else { + err := portal.CreateMatrixRoom(user, v.ConversationEvent.GetData()) + if err != nil { + user.zlog.Err(err).Msg("Error creating Matrix room from conversation event") + } + } + case *binary.Event_MessageEvent: + portal := user.GetPortalByID(v.MessageEvent.GetData().GetConversationID()) + portal.messages <- PortalMessage{evt: v.MessageEvent.GetData(), source: user} + case *events.ClientReady: + user.zlog.Trace().Any("data", v).Msg("Client is ready!") + case *events.BrowserActive: + user.zlog.Trace().Any("data", v).Msg("Browser active") + case *events.Battery: + user.zlog.Trace().Any("data", v).Msg("Battery") + case *events.DataConnection: + user.zlog.Trace().Any("data", v).Msg("Data connection") + default: + user.zlog.Trace().Any("data", v).Msg("Unknown event") + } +} + +func (user *User) updateChatMute(portal *Portal, mutedUntil time.Time) { + intent := user.DoublePuppetIntent + if intent == nil || len(portal.MXID) == 0 { + return + } + var err error + if mutedUntil.IsZero() && mutedUntil.Before(time.Now()) { + user.log.Debugfln("Portal %s is muted until %s, unmuting...", portal.MXID, mutedUntil) + err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID)) + } else { + user.log.Debugfln("Portal %s is muted until %s, muting...", portal.MXID, mutedUntil) + err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ + Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, + }) + } + if err != nil && !errors.Is(err, mautrix.MNotFound) { + user.log.Warnfln("Failed to update push rule for %s through double puppet: %v", portal.MXID, err) + } +} + +type CustomTagData struct { + Order json.Number `json:"order"` + DoublePuppet string `json:"fi.mau.double_puppet_source"` +} + +type CustomTagEventContent struct { + Tags map[string]CustomTagData `json:"tags"` +} + +func (user *User) updateChatTag(portal *Portal, tag string, active bool) { + intent := user.DoublePuppetIntent + if intent == nil || len(portal.MXID) == 0 { + return + } + var existingTags CustomTagEventContent + err := intent.GetTagsWithCustomData(portal.MXID, &existingTags) + if err != nil && !errors.Is(err, mautrix.MNotFound) { + user.log.Warnfln("Failed to get tags of %s: %v", portal.MXID, err) + } + currentTag, ok := existingTags.Tags[tag] + if active && !ok { + user.log.Debugln("Adding tag", tag, "to", portal.MXID) + data := CustomTagData{Order: "0.5", DoublePuppet: user.bridge.Name} + err = intent.AddTagWithCustomData(portal.MXID, tag, &data) + } else if !active && ok && currentTag.DoublePuppet == user.bridge.Name { + user.log.Debugln("Removing tag", tag, "from", portal.MXID) + err = intent.RemoveTag(portal.MXID, tag) + } else { + err = nil + } + if err != nil { + user.log.Warnfln("Failed to update tag %s for %s through double puppet: %v", tag, portal.MXID, err) + } +} + +type CustomReadReceipt struct { + Timestamp int64 `json:"ts,omitempty"` + DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` +} + +type CustomReadMarkers struct { + mautrix.ReqSetReadMarkers + ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"` + FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"` +} + +func (user *User) syncChatDoublePuppetDetails(portal *Portal, conv *binary.Conversation, justCreated bool) { + if user.DoublePuppetIntent == nil || len(portal.MXID) == 0 { + return + } + if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate { + //user.updateChatMute(portal, chat.MutedUntil) + //user.updateChatTag(portal, user.bridge.Config.Bridge.ArchiveTag, conv.Status == 2) + //user.updateChatTag(portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned) + } +} + +func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) { + if !user.bridge.Config.Bridge.SyncDirectChatList || user.DoublePuppetIntent == nil { + return + } + intent := user.DoublePuppetIntent + method := http.MethodPatch + //if chats == nil { + // chats = user.getDirectChats() + // method = http.MethodPut + //} + user.zlog.Debug().Msg("Updating m.direct list on homeserver") + var err error + if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux { + urlPath := intent.BuildClientURL("unstable", "com.beeper.asmux", "dms") + _, err = intent.MakeFullRequest(mautrix.FullRequest{ + Method: method, + URL: urlPath, + Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}}, + RequestJSON: chats, + }) + } else { + existingChats := make(map[id.UserID][]id.RoomID) + err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) + if err != nil { + user.log.Warnln("Failed to get m.direct list to update it:", err) + 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(event.AccountDataDirectChats.Type, &chats) + } + if err != nil { + user.log.Warnln("Failed to update m.direct list:", err) + } +} + +func (user *User) markUnread(portal *Portal, unread bool) { + if user.DoublePuppetIntent == nil { + return + } + + log := user.zlog.With().Str("room_id", portal.MXID.String()).Logger() + + err := user.DoublePuppetIntent.SetRoomAccountData(portal.MXID, "m.marked_unread", map[string]bool{"unread": unread}) + if err != nil { + log.Warn().Err(err).Str("event_type", "m.marked_unread"). + Msg("Failed to mark room as unread") + } else { + log.Debug().Str("event_type", "m.marked_unread").Msg("Marked room as unread") + } + + err = user.DoublePuppetIntent.SetRoomAccountData(portal.MXID, "com.famedly.marked_unread", map[string]bool{"unread": unread}) + if err != nil { + log.Warn().Err(err).Str("event_type", "com.famedly.marked_unread"). + Msg("Failed to mark room as unread") + } else { + log.Debug().Str("event_type", "com.famedly.marked_unread").Msg("Marked room as unread") + } +}