// 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" "sync/atomic" "time" "github.com/rs/zerolog" "google.golang.org/protobuf/proto" "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/commands" "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/events" "go.mau.fi/mautrix-gmessages/libgm/gmproto" ) 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 CommandState *commands.CommandState spaceMembershipChecked bool longPollingError error browserInactiveType status.BridgeStateErrorCode switchedToGoogleLogin bool batteryLow bool mobileData bool phoneResponding bool ready bool sessionID string batteryLowAlertSent time.Time pollErrorAlertSent bool phoneNotRespondingAlertSent bool loginInProgress atomic.Bool pairSuccessChan chan struct{} ongoingLoginChan <-chan qrChannelItem lastQRCode string cancelLogin func() DoublePuppetIntent *appservice.IntentAPI } func (user *User) GetCommandState() *commands.CommandState { return user.CommandState } func (user *User) SetCommandState(state *commands.CommandState) { user.CommandState = state } var _ commands.CommandingUser = (*User)(nil) 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 (br *GMBridge) GetUserByMXIDIfExists(userID id.UserID) *User { return br.getUserByMXID(userID, true) } 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 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.longPollingError = errors.New("not connected") user.phoneResponding = true 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") var ErrLoginInProgress = errors.New("login already in progress") var ErrLoginTimeout = errors.New("login timed out") func (user *User) createClient(sess *libgm.AuthData) { user.Client = libgm.NewClient(sess, user.zlog.With().Str("component", "libgm").Logger()) user.Client.SetEventHandler(user.syncHandleEvent) } type qrChannelItem struct { success bool qr string err error } func (qci qrChannelItem) IsEmpty() bool { return !qci.success && qci.qr == "" && qci.err == nil } func (user *User) Login(maxAttempts int) (<-chan qrChannelItem, error) { user.connLock.Lock() defer user.connLock.Unlock() if user.Session != nil { return nil, ErrAlreadyLoggedIn } else if !user.loginInProgress.CompareAndSwap(false, true) { return user.ongoingLoginChan, ErrLoginInProgress } if user.Client != nil { user.unlockedDeleteConnection() } pairSuccessChan := make(chan struct{}) user.pairSuccessChan = pairSuccessChan user.createClient(libgm.NewAuthData()) qr, err := user.Client.StartLogin() if err != nil { user.unlockedDeleteConnection() user.pairSuccessChan = nil user.loginInProgress.Store(false) return nil, fmt.Errorf("failed to connect to Google Messages: %w", err) } Analytics.Track(user.MXID, "$login_start") ch := make(chan qrChannelItem, maxAttempts+2) ctx, cancel := context.WithCancel(context.Background()) user.cancelLogin = cancel user.ongoingLoginChan = ch ch <- qrChannelItem{qr: qr} user.lastQRCode = qr go func() { ticker := time.NewTicker(30 * time.Second) success := false defer func() { ticker.Stop() if !success { user.zlog.Debug().Msg("Deleting connection as login wasn't successful") user.DeleteConnection() } user.pairSuccessChan = nil user.ongoingLoginChan = nil user.lastQRCode = "" close(ch) user.loginInProgress.Store(false) cancel() user.cancelLogin = nil }() for { maxAttempts-- select { case <-ctx.Done(): user.zlog.Debug().Err(ctx.Err()).Msg("Login context cancelled") return case <-ticker.C: if maxAttempts <= 0 { ch <- qrChannelItem{err: ErrLoginTimeout} return } qr, err := user.Client.RefreshPhoneRelay() if err != nil { ch <- qrChannelItem{err: fmt.Errorf("failed to refresh QR code: %w", err)} return } ch <- qrChannelItem{qr: qr} user.lastQRCode = qr case <-pairSuccessChan: ch <- qrChannelItem{success: true} success = true return } } }() return ch, nil } func (user *User) LoginGoogle(cookies map[string]string, emojiCallback func(string)) error { user.connLock.Lock() defer user.connLock.Unlock() if user.Session != nil { return ErrAlreadyLoggedIn } else if !user.loginInProgress.CompareAndSwap(false, true) { return ErrLoginInProgress } if user.Client != nil { user.unlockedDeleteConnection() } pairSuccessChan := make(chan struct{}) user.pairSuccessChan = pairSuccessChan authData := libgm.NewAuthData() authData.Cookies = cookies user.createClient(authData) Analytics.Track(user.MXID, "$login_start") err := user.Client.DoGaiaPairing(emojiCallback) if err != nil { user.unlockedDeleteConnection() user.pairSuccessChan = nil user.loginInProgress.Store(false) return fmt.Errorf("failed to connect to Google Messages: %w", err) } return 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 } if len(user.AccessToken) == 0 { user.tryAutomaticDoublePuppeting() } user.zlog.Debug().Msg("Connecting to Google Messages") user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting, Error: GMConnecting}) user.createClient(user.Session) err := user.Client.Connect() if err != nil { user.zlog.Err(err).Msg("Error connecting to Google Messages") if errors.Is(err, events.ErrRequestedEntityNotFound) { go user.Logout(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: GMUnpaired404, Info: map[string]any{ "go_error": err.Error(), }, }, false) } else { user.BridgeState.Send(status.BridgeState{ StateEvent: status.StateUnknownError, Error: GMConnectionFailed, 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() user.longPollingError = errors.New("not connected") user.phoneResponding = true user.batteryLow = false user.switchedToGoogleLogin = false user.ready = false user.browserInactiveType = "" } func (user *User) HasSession() bool { return user.Session != nil } func (user *User) DeleteSession() { user.Session = nil user.SelfParticipantIDs = []string{} 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) sendMarkdownBridgeAlert(important bool, formatString string, args ...interface{}) { if user.bridge.Config.Bridge.DisableBridgeAlerts { return } notice := fmt.Sprintf(formatString, args...) content := format.RenderMarkdown(notice, true, false) if !important { content.MsgType = event.MsgNotice } _, 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) syncHandleEvent(event any) { switch v := event.(type) { case *events.ListenFatalError: go user.Logout(status.BridgeState{ StateEvent: status.StateUnknownError, Error: GMFatalError, Info: map[string]any{"go_error": v.Error.Error()}, }, false) go user.sendMarkdownBridgeAlert(true, "Fatal error while listening to Google Messages: %v - Log in again to continue using the bridge", v.Error) case *events.ListenTemporaryError: user.longPollingError = v.Error user.BridgeState.Send(status.BridgeState{ StateEvent: status.StateTransientDisconnect, Error: GMListenError, Info: map[string]any{"go_error": v.Error.Error()}, }) if !user.pollErrorAlertSent { go user.sendMarkdownBridgeAlert(false, "Temporary error while listening to Google Messages: %v", v.Error) user.pollErrorAlertSent = true } case *events.ListenRecovered: user.longPollingError = nil user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) if user.pollErrorAlertSent { go user.sendMarkdownBridgeAlert(false, "Reconnected to Google Messages") user.pollErrorAlertSent = false } case *events.PhoneNotResponding: user.phoneResponding = false user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) // TODO make this properly configurable if user.zlog.Trace().Enabled() && !user.phoneNotRespondingAlertSent { go user.sendMarkdownBridgeAlert(false, "Phone is not responding") user.phoneNotRespondingAlertSent = true } case *events.PhoneRespondingAgain: user.phoneResponding = true user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) if user.phoneNotRespondingAlertSent { go user.sendMarkdownBridgeAlert(false, "Phone is responding again") user.phoneNotRespondingAlertSent = false } case *events.PingFailed: if errors.Is(v.Error, events.ErrRequestedEntityNotFound) { go user.Logout(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: GMUnpaired404, Info: map[string]any{ "go_error": v.Error.Error(), }, }, false) } else if v.ErrorCount > 1 { user.BridgeState.Send(status.BridgeState{ StateEvent: status.StateUnknownError, Error: GMPingFailed, Info: map[string]any{"go_error": v.Error.Error()}, }) } else { user.zlog.Debug().Msg("Not sending unknown error for first ping fail") } case *events.PairSuccessful: user.Session = user.Client.AuthData if user.PhoneID != "" && user.PhoneID != v.GetMobile().GetSourceID() { user.zlog.Warn(). Str("old_phone_id", user.PhoneID). Str("new_phone_id", v.GetMobile().GetSourceID()). Msg("Phone ID changed, resetting state") user.ResetState() } user.PhoneID = v.GetMobile().GetSourceID() err := user.Update(context.TODO()) if err != nil { user.zlog.Err(err).Msg("Failed to update session in database") } if ch := user.pairSuccessChan; ch != nil { close(ch) } go user.tryAutomaticDoublePuppeting() case *gmproto.RevokePairData: user.zlog.Info().Any("revoked_device", v.GetRevokedDevice()).Msg("Got pair revoked event") go user.Logout(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: GMUnpaired, }, false) go user.sendMarkdownBridgeAlert(true, "Unpaired from Google Messages. Log in again to continue using the bridge.") case *events.AuthTokenRefreshed: go func() { err := user.Update(context.TODO()) if err != nil { user.zlog.Err(err).Msg("Failed to update session in database") } }() case *gmproto.Conversation: go user.syncConversation(v, "event") //case *gmproto.Message: case *libgm.WrappedMessage: user.zlog.Debug(). Str("conversation_id", v.GetConversationID()). Str("participant_id", v.GetParticipantID()). Str("message_id", v.GetMessageID()). Str("message_status", v.GetMessageStatus().GetStatus().String()). Int64("message_ts", v.GetTimestamp()). Str("tmp_id", v.GetTmpID()). Bool("is_old", v.IsOld). Msg("Received message") portal := user.GetPortalByID(v.GetConversationID()) portal.messages <- PortalMessage{evt: v.Message, source: user, raw: v.Data} case *gmproto.UserAlertEvent: user.handleUserAlert(v) case *gmproto.Settings: user.handleSettings(v) case *events.AccountChange: user.handleAccountChange(v) default: user.zlog.Trace().Any("data", v).Type("data_type", v).Msg("Unknown event") } } func (user *User) ResetState() { portals := user.bridge.GetAllPortalsForUser(user.RowID) user.zlog.Debug().Int("portal_count", len(portals)).Msg("Deleting portals") for _, portal := range portals { portal.Delete() } user.bridge.DeleteAllPuppetsForUser(user.RowID) user.PhoneID = "" go func() { user.zlog.Debug().Msg("Cleaning up portal rooms in background") for _, portal := range portals { portal.Cleanup() } user.zlog.Debug().Msg("Finished cleaning up portals") }() } func (user *User) aggressiveSetActive() { sleepTimes := []int{5, 10, 30} for i := 0; i < 3; i++ { sleep := time.Duration(sleepTimes[i]) * time.Second user.zlog.Info(). Int("sleep_seconds", int(sleep.Seconds())). Msg("Aggressively reactivating bridge session after sleep") time.Sleep(sleep) if user.browserInactiveType == "" { user.zlog.Info().Msg("Bridge session became active on its own, not reactivating") return } user.zlog.Info().Msg("Now reactivating bridge session") err := user.Client.SetActiveSession() if err != nil { user.zlog.Warn().Err(err).Msg("Failed to set self as active session") } else { break } } } func (user *User) fetchAndSyncConversations() { user.zlog.Info().Msg("Fetching conversation list") resp, err := user.Client.ListConversations(user.bridge.Config.Bridge.InitialChatSyncCount, gmproto.ListConversationsRequest_INBOX) if err != nil { user.zlog.Err(err).Msg("Failed to get conversation list") return } user.zlog.Info().Int("count", len(resp.GetConversations())).Msg("Syncing conversations") for _, conv := range resp.GetConversations() { user.syncConversation(conv, "sync") } } func (user *User) handleAccountChange(v *events.AccountChange) { user.zlog.Debug(). Str("account", v.GetAccount()). Bool("enabled", v.GetEnabled()). Bool("fake", v.IsFake). Msg("Got account change event") user.switchedToGoogleLogin = v.GetEnabled() || v.IsFake if !v.IsFake { if user.switchedToGoogleLogin { go user.sendMarkdownBridgeAlert(true, "The bridge will not work when the account-based pairing method is enabled in the Google Messages app. Unlink other devices and switch back to the QR code method to continue using the bridge.") } else { go user.sendMarkdownBridgeAlert(false, "Switched back to QR pairing, bridge should work now") // Assume connection is ready now even if it wasn't before user.ready = true } } user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } func (user *User) handleUserAlert(v *gmproto.UserAlertEvent) { user.zlog.Debug().Str("alert_type", v.GetAlertType().String()).Msg("Got user alert event") becameInactive := false switch v.GetAlertType() { case gmproto.AlertType_BROWSER_INACTIVE: user.browserInactiveType = GMBrowserInactive becameInactive = true case gmproto.AlertType_BROWSER_ACTIVE: wasInactive := user.browserInactiveType != "" || !user.ready user.pollErrorAlertSent = false user.browserInactiveType = "" user.ready = true newSessionID := user.Client.CurrentSessionID() if user.sessionID != newSessionID || wasInactive { user.zlog.Debug(). Str("old_session_id", user.sessionID). Str("new_session_id", newSessionID). Msg("Session ID changed for browser active event, resyncing") user.sessionID = newSessionID go user.fetchAndSyncConversations() go user.sendMarkdownBridgeAlert(false, "Connected to Google Messages") } else { user.zlog.Debug(). Str("session_id", user.sessionID). Msg("Session ID didn't change for browser active event, not resyncing") } case gmproto.AlertType_BROWSER_INACTIVE_FROM_TIMEOUT: user.browserInactiveType = GMBrowserInactiveTimeout becameInactive = true case gmproto.AlertType_BROWSER_INACTIVE_FROM_INACTIVITY: user.browserInactiveType = GMBrowserInactiveInactivity becameInactive = true case gmproto.AlertType_MOBILE_DATA_CONNECTION: user.mobileData = true case gmproto.AlertType_MOBILE_WIFI_CONNECTION: user.mobileData = false case gmproto.AlertType_MOBILE_BATTERY_LOW: user.batteryLow = true if time.Since(user.batteryLowAlertSent) > 30*time.Minute { go user.sendMarkdownBridgeAlert(true, "Your phone's battery is low") user.batteryLowAlertSent = time.Now() } case gmproto.AlertType_MOBILE_BATTERY_RESTORED: user.batteryLow = false if !user.batteryLowAlertSent.IsZero() { go user.sendMarkdownBridgeAlert(false, "Phone battery restored") user.batteryLowAlertSent = time.Time{} } default: return } if becameInactive { if user.bridge.Config.GoogleMessages.AggressiveReconnect { go user.aggressiveSetActive() } else { go user.sendMarkdownBridgeAlert(true, "Google Messages was opened in another browser. Use `set-active` to reconnect the bridge.") } } user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } func (user *User) handleSettings(settings *gmproto.Settings) { if settings.SIMCards == nil { return } ctx := context.TODO() changed := user.SetSIMs(settings.SIMCards) newRCSSettings := settings.GetRCSSettings() if user.Settings.RCSEnabled != newRCSSettings.GetIsEnabled() || user.Settings.ReadReceipts != newRCSSettings.GetSendReadReceipts() || user.Settings.TypingNotifications != newRCSSettings.GetShowTypingIndicators() || user.Settings.IsDefaultSMSApp != newRCSSettings.GetIsDefaultSMSApp() || !user.Settings.SettingsReceived { user.Settings = database.Settings{ SettingsReceived: true, RCSEnabled: newRCSSettings.GetIsEnabled(), ReadReceipts: newRCSSettings.GetSendReadReceipts(), TypingNotifications: newRCSSettings.GetShowTypingIndicators(), IsDefaultSMSApp: newRCSSettings.GetIsDefaultSMSApp(), } changed = true } if changed { err := user.Update(ctx) if err != nil { user.zlog.Err(err).Msg("Failed to save SIM details") } user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } } func (user *User) FillBridgeState(state status.BridgeState) status.BridgeState { if state.Info == nil { state.Info = make(map[string]any) } if state.StateEvent == status.StateConnected { state.Info["sims"] = user.GetSIMsForBridgeState() state.Info["settings"] = user.Settings state.Info["battery_low"] = user.batteryLow state.Info["mobile_data"] = user.mobileData state.Info["browser_active"] = user.browserInactiveType == "" state.Info["google_account_pairing"] = user.switchedToGoogleLogin if !user.ready { state.StateEvent = status.StateConnecting state.Error = GMConnecting } if !user.phoneResponding { state.StateEvent = status.StateBadCredentials state.Error = GMPhoneNotResponding } if user.switchedToGoogleLogin { state.StateEvent = status.StateBadCredentials state.Error = GMSwitchedToGoogleLogin } if user.longPollingError != nil { state.StateEvent = status.StateTransientDisconnect state.Error = GMListenError state.Info["go_error"] = user.longPollingError.Error() } if user.browserInactiveType != "" { if user.bridge.Config.GoogleMessages.AggressiveReconnect { state.StateEvent = status.StateTransientDisconnect } else { state.StateEvent = status.StateBadCredentials } state.Error = user.browserInactiveType } } return state } func (user *User) Logout(state status.BridgeState, unpair bool) (logoutOK bool) { if user.Client != nil && unpair { _, err := user.Client.Unpair() if err != nil { user.zlog.Debug().Err(err).Msg("Error sending unpair request") } else { logoutOK = true } } user.DeleteConnection() user.DeleteSession() user.BridgeState.Send(state) return } func conversationDataIsSus(portal *Portal, v *gmproto.Conversation) bool { if !portal.IsPrivateChat() { // Group chats hopefully never get bad updates return false } else if v.IsGroupChat { // Group chat update for a DM is always sus return true } count := 0 mainName := "" for _, pcp := range v.Participants { if !pcp.IsMe { if count == 0 { mainName = pcp.FullName count++ } else if mainName != pcp.FullName { count++ } } } // If there are multiple names in a DM, that's sus even if it's not marked as a group return count > 1 } func (user *User) syncConversation(v *gmproto.Conversation, source string) { updateType := v.GetStatus() portal := user.GetPortalByID(v.GetConversationID()) convCopy := proto.Clone(v).(*gmproto.Conversation) convCopy.LatestMessage = nil log := portal.zlog.With(). Str("action", "sync conversation"). Str("conversation_status", updateType.String()). Str("data_source", source). Interface("conversation_data", convCopy). Logger() if cancel := portal.cancelCreation.Load(); cancel != nil { if updateType == gmproto.ConversationStatus_SPAM_FOLDER || updateType == gmproto.ConversationStatus_BLOCKED_FOLDER { (*cancel)(fmt.Errorf("conversation was moved to spam")) } else if updateType == gmproto.ConversationStatus_DELETED { (*cancel)(fmt.Errorf("conversation was deleted")) portal.Delete() } else { log.Debug().Msg("Conversation creation is still pending, ignoring new sync event") return } } if portal.MXID != "" { switch updateType { case gmproto.ConversationStatus_DELETED: log.Info().Msg("Got delete event, cleaning up portal") portal.Delete() portal.Cleanup() case gmproto.ConversationStatus_SPAM_FOLDER, gmproto.ConversationStatus_BLOCKED_FOLDER: log.Info().Msg("Got spam/block event, cleaning up portal") portal.Cleanup() portal.RemoveMXID(context.TODO()) default: if v.Participants == nil { log.Debug().Msg("Not syncing conversation with nil participants") return } else if conversationDataIsSus(portal, v) { log.Warn().Msg("Ignoring suspicious update for private chat") return } log.Debug().Msg("Syncing existing portal") portal.UpdateMetadata(user, v) user.syncChatDoublePuppetDetails(portal, v, false) go portal.missedForwardBackfill( user, time.UnixMicro(v.LastMessageTimestamp), v.LatestMessageID, !v.GetUnread(), source == "event", ) } } else if updateType == gmproto.ConversationStatus_ACTIVE || updateType == gmproto.ConversationStatus_ARCHIVED { if v.Participants == nil { log.Debug().Msg("Not syncing conversation with nil participants") return } if source == "event" { go func() { ctx, cancel := context.WithCancelCause(context.TODO()) cancelPtr := &cancel defer func() { portal.cancelCreation.CompareAndSwap(cancelPtr, nil) cancel(nil) }() portal.cancelCreation.Store(cancelPtr) log.Debug().Msg("Creating portal for conversation in 5 seconds") select { case <-time.After(5 * time.Second): case <-ctx.Done(): log.Debug().Err(ctx.Err()).Msg("Portal creation was cancelled") return } err := portal.CreateMatrixRoom(user, v, source == "sync") if err != nil { log.Err(err).Msg("Error creating Matrix room from conversation event") } }() } else { log.Debug().Msg("Creating portal for conversation") err := portal.CreateMatrixRoom(user, v, source == "sync") if err != nil { log.Err(err).Msg("Error creating Matrix room from conversation event") } } } else { log.Debug().Msg("Not creating portal for conversation") } } 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, existingTags CustomTagEventContent) { var err error currentTag, ok := existingTags.Tags[tag] if active && !ok { user.zlog.Debug().Str("tag", tag).Str("room_id", portal.MXID.String()).Msg("Adding room tag") data := CustomTagData{Order: "0.5", DoublePuppet: user.bridge.Name} err = user.DoublePuppetIntent.AddTagWithCustomData(portal.MXID, tag, &data) } else if !active && ok && currentTag.DoublePuppet == user.bridge.Name { user.zlog.Debug().Str("tag", tag).Str("room_id", portal.MXID.String()).Msg("Removing room tag") err = user.DoublePuppetIntent.RemoveTag(portal.MXID, tag) } else { err = nil } if err != nil { user.zlog.Warn().Err(err).Str("room_id", portal.MXID.String()).Msg("Failed to update room tag") } } 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) markSelfReadFull(portal *Portal, lastMessageID string) { if user.DoublePuppetIntent == nil || portal.lastUserReadID == lastMessageID { return } ctx := context.TODO() lastMessage, err := user.bridge.DB.Message.GetByID(ctx, portal.Receiver, lastMessageID) if err == nil && lastMessage == nil || lastMessage.IsFakeMXID() { lastMessage, err = user.bridge.DB.Message.GetLastInChatWithMXID(ctx, portal.Key) } if err != nil { user.zlog.Warn().Err(err).Msg("Failed to get last message in chat to mark it as read") return } else if lastMessage == nil || portal.lastUserReadID == lastMessage.ID { return } log := user.zlog.With(). Str("conversation_id", portal.ID). Str("message_id", lastMessage.ID). Str("room_id", portal.ID). Str("event_id", lastMessage.MXID.String()). Logger() err = user.DoublePuppetIntent.SetReadMarkers(portal.MXID, &CustomReadMarkers{ ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ Read: lastMessage.MXID, FullyRead: lastMessage.MXID, }, ReadExtra: CustomReadReceipt{DoublePuppetSource: user.bridge.Name}, FullyReadExtra: CustomReadReceipt{DoublePuppetSource: user.bridge.Name}, }) if err != nil { log.Warn().Err(err).Msg("Failed to mark last message in chat as read") } else { log.Debug().Msg("Marked last message in chat as read") portal.lastUserReadID = lastMessage.ID } } func (user *User) syncChatDoublePuppetDetails(portal *Portal, conv *gmproto.Conversation, justCreated bool) { if user.DoublePuppetIntent == nil || len(portal.MXID) == 0 { return } if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate { var existingTags CustomTagEventContent err := user.DoublePuppetIntent.GetTagsWithCustomData(portal.MXID, &existingTags) if err != nil && !errors.Is(err, mautrix.MNotFound) { user.zlog.Warn().Err(err).Str("room_id", portal.MXID.String()).Msg("Failed to get existing room tags") } user.updateChatTag(portal, user.bridge.Config.Bridge.ArchiveTag, conv.Status == gmproto.ConversationStatus_ARCHIVED || conv.Status == gmproto.ConversationStatus_KEEP_ARCHIVED, existingTags) user.updateChatTag(portal, user.bridge.Config.Bridge.PinnedTag, conv.Pinned, existingTags) } } 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") } }