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")
+ }
+}