diff --git a/bridgev2/bridgeconfig/appservice.go b/bridgev2/bridgeconfig/appservice.go index 6c609d75..759e5c2a 100644 --- a/bridgev2/bridgeconfig/appservice.go +++ b/bridgev2/bridgeconfig/appservice.go @@ -118,6 +118,7 @@ type BotUserConfig struct { Username string `yaml:"username"` Displayname string `yaml:"displayname"` Avatar string `yaml:"avatar"` + AccessToken string `yaml:"access_token"` ParsedAvatar id.ContentURI `yaml:"-"` } diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 56b19aac..ee342bfb 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -94,6 +94,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Str, "appservice", "bot", "username") helper.Copy(up.Str, "appservice", "bot", "displayname") helper.Copy(up.Str, "appservice", "bot", "avatar") + helper.Copy(up.Str, "appservice", "bot", "access_token") helper.Copy(up.Bool, "appservice", "ephemeral_events") helper.Copy(up.Bool, "appservice", "async_transactions") helper.Copy(up.Str, "appservice", "as_token") diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 733457bd..465bf5f4 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -58,6 +58,7 @@ type Crypto interface { Client() *mautrix.Client ShareKeys(context.Context) error BeeperStreamPublisher() bridgev2.BeeperStreamPublisher + ProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) bool } type Connector struct { @@ -87,9 +88,14 @@ type Connector struct { Capabilities *bridgev2.MatrixCapabilities IgnoreUnsupportedServer bool + BotMode bool + stopBotSync context.CancelFunc + botSyncDone sync.WaitGroup + EventProcessor *appservice.EventProcessor userIDRegex *regexp.Regexp + whoami *mautrix.RespWhoami Websocket bool wsStopPinger chan struct{} @@ -131,6 +137,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.AS = br.Config.MakeAppService() br.AS.Log = bridge.Log br.AS.StateStore = br.StateStore + br.Bot = br.AS.BotIntent() br.EventProcessor = appservice.NewEventProcessor(br.AS) if !br.Config.AppService.AsyncTransactions { br.EventProcessor.ExecMode = appservice.Sync @@ -148,11 +155,11 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { event.StateMember, event.StatePowerLevels, event.StateRoomName, - event.BeeperSendState, event.StateRoomAvatar, event.StateTopic, event.StateTombstone, event.StateBeeperDisappearingTimer, + event.BeeperSendState, event.BeeperDeleteChat, event.BeeperAcceptMessageRequest, event.EphemeralEventReceipt, @@ -163,7 +170,6 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.EventEncrypted, br.handleEncryptedEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) - br.Bot = br.AS.BotIntent() br.Crypto = NewCryptoHelper(br) br.Bridge.Commands.(*commands.Processor).AddHandlers( CommandDiscardMegolmSession, CommandSetPowerLevel, @@ -172,6 +178,15 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.Provisioning = &ProvisioningAPI{br: br} br.DoublePuppet = newDoublePuppetUtil(br) br.deterministicEventIDServer = "backfill." + br.Config.Homeserver.Domain + if br.Config.AppService.Bot.AccessToken != "" { + br.Bot.SetAppServiceUserID = false + br.Bot.SetAppServiceDeviceID = false + br.Bot.Registered = true + br.Bot.AccessToken = br.Config.AppService.Bot.AccessToken + br.AS.Registration.AppToken = "" + br.BotMode = true + br.configureRelaySyncer() + } } func (br *Connector) Start(ctx context.Context) error { @@ -236,6 +251,9 @@ func (br *Connector) Start(ctx context.Context) error { br.deterministicEventIDServer = strings.TrimPrefix(parsed.Hostname(), "www.") } br.AS.Ready = true + if br.BotMode { + go br.startRelaySyncer() + } if br.Websocket && br.Config.Homeserver.WSPingInterval > 0 { br.wsStopPinger = make(chan struct{}, 1) go br.websocketServerPinger() @@ -334,6 +352,9 @@ func (br *Connector) Stop() { if br.Crypto != nil { br.Crypto.Stop() } + if br.BotMode { + br.stopRelaySyncer() + } if wsStopChan := br.wsStopped; wsStopChan != nil { select { case <-wsStopChan: @@ -419,16 +440,20 @@ func (br *Connector) ensureConnection(ctx context.Context) { Msg("Unexpected user ID in whoami call") os.Exit(17) } + br.whoami = resp + if br.BotMode { + br.Bot.DeviceID = resp.DeviceID + } if br.Websocket { br.Log.Debug().Msg("Websocket mode: no need to check status of homeserver -> bridge connection") - return + } else if br.BotMode { + br.Log.Debug().Msg("Bot mode: no need to check status of homeserver -> bridge connection") } else if !br.SpecVersions.Supports(mautrix.FeatureAppservicePing) { br.Log.Debug().Msg("Homeserver does not support checking status of homeserver -> bridge connection") - return + } else { + br.Bot.EnsureAppserviceConnection(ctx) } - - br.Bot.EnsureAppserviceConnection(ctx) } func (br *Connector) fetchCapabilities(ctx context.Context) *mautrix.RespCapabilities { @@ -502,6 +527,12 @@ func (br *Connector) UpdateBotProfile(ctx context.Context) { } func (br *Connector) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { + if br.BotMode { + return &RelayIntent{ + ASIntent: br.Bridge.Bot.(*ASIntent), + ID: userID, + } + } return &ASIntent{ Matrix: br.AS.Intent(br.FormatGhostMXID(userID)), Connector: br, @@ -602,6 +633,9 @@ func (br *Connector) SendMessageCheckpoints(ctx context.Context, checkpoints []* } func (br *Connector) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) { + if br.BotMode { + return "", false + } match := br.userIDRegex.FindStringSubmatch(string(userID)) if match == nil || userID == br.Bot.UserID { return "", false diff --git a/bridgev2/matrix/crypto.go b/bridgev2/matrix/crypto.go index e4ebb2b1..7dddfe8f 100644 --- a/bridgev2/matrix/crypto.go +++ b/bridgev2/matrix/crypto.go @@ -80,8 +80,8 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { helper.bridge.Bridge.DB.Database, dbutil.ZeroLogger(helper.bridge.Log.With().Str("db_section", "crypto").Logger()), string(helper.bridge.Bridge.ID), - helper.bridge.AS.BotMXID(), - fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.AS.HomeserverDomain), + helper.bridge.Bot.UserID, + fmt.Sprintf("@%s:%s", strings.ReplaceAll(helper.bridge.Config.AppService.FormatUsername("%"), "_", `\_`), helper.bridge.Config.Homeserver.Domain), helper.bridge.Config.Encryption.PickleKey, ) @@ -91,7 +91,12 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { } var isExistingDevice bool - helper.client, isExistingDevice, err = helper.loginBot(ctx) + if helper.bridge.BotMode { + helper.client = helper.bridge.Bot.Client + isExistingDevice, err = helper.checkBotDevice(ctx) + } else { + helper.client, isExistingDevice, err = helper.loginBot(ctx) + } if err != nil { return err } @@ -135,8 +140,10 @@ func (helper *CryptoHelper) Init(ctx context.Context) error { return err } helper.streams = streams - helper.client.Syncer = &cryptoSyncer{OlmMachine: helper.mach, handleSyncResponse: streams.HandleSyncResponse} - helper.client.Store = helper.store + if !helper.bridge.BotMode { + helper.client.Syncer = &cryptoSyncer{OlmMachine: helper.mach, handleSyncResponse: streams.HandleSyncResponse} + helper.client.Store = helper.store + } err = helper.mach.Load(ctx) if err != nil { @@ -287,6 +294,16 @@ func (helper *CryptoHelper) allowKeyShare(ctx context.Context, device *id.Device return &crypto.KeyShareRejectUnverified } } +func (helper *CryptoHelper) checkBotDevice(ctx context.Context) (bool, error) { + deviceID, err := helper.store.FindDeviceID(ctx) + if err != nil { + return false, fmt.Errorf("failed to find existing device ID: %w", err) + } else if deviceID != "" && deviceID != helper.client.DeviceID { + return false, fmt.Errorf("device ID from database doesn't match bot's actual device ID: %s != %s", deviceID, helper.bridge.whoami.DeviceID) + } + helper.store.DeviceID = helper.client.DeviceID + return deviceID != "", nil +} func (helper *CryptoHelper) loginBot(ctx context.Context) (*mautrix.Client, bool, error) { deviceID, err := helper.store.FindDeviceID(ctx) @@ -355,6 +372,9 @@ func (helper *CryptoHelper) verifyKeysAreOnServer(ctx context.Context) bool { } func (helper *CryptoHelper) Start() { + if helper.bridge.BotMode { + return + } if helper.bridge.Config.Encryption.Appservice { helper.log.Debug().Msg("End-to-bridge encryption is in appservice mode, registering event listeners and not starting syncer") helper.bridge.AS.Registration.EphemeralEvents = true @@ -383,7 +403,9 @@ func (helper *CryptoHelper) Start() { func (helper *CryptoHelper) Stop() { helper.log.Debug().Msg("CryptoHelper.Stop() called, stopping bridge bot sync") - helper.client.StopSync() + if !helper.bridge.BotMode { + helper.client.StopSync() + } if helper.cancelSync != nil { helper.cancelSync() } @@ -537,6 +559,13 @@ func (helper *CryptoHelper) BeeperStreamPublisher() bridgev2.BeeperStreamPublish return helper.streams } +func (helper *CryptoHelper) ProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) bool { + helper.lock.RLock() + m := helper.mach + helper.lock.RUnlock() + return m.ProcessSyncResponse(ctx, resp, since) +} + type cryptoSyncer struct { *crypto.OlmMachine handleSyncResponse func(context.Context, *mautrix.RespSync) []*event.Event diff --git a/bridgev2/matrix/doublepuppet.go b/bridgev2/matrix/doublepuppet.go index ace33f30..ec10d02e 100644 --- a/bridgev2/matrix/doublepuppet.go +++ b/bridgev2/matrix/doublepuppet.go @@ -41,7 +41,7 @@ func (dp *doublePuppetUtil) newClient(ctx context.Context, mxid id.UserID, acces } homeserverURL, found := dp.br.Config.DoublePuppet.Servers[homeserver] if !found { - if homeserver == dp.br.AS.HomeserverDomain { + if homeserver == dp.br.Config.Homeserver.Domain { homeserverURL = "" } else if dp.br.Config.DoublePuppet.AllowDiscovery { dp.discoveryCacheLock.Lock() diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index 30bdfe0a..d37beb3a 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -45,6 +45,7 @@ type ASIntent struct { var _ bridgev2.MatrixAPI = (*ASIntent)(nil) var _ bridgev2.MarkAsDMMatrixAPI = (*ASIntent)(nil) +var _ bridgev2.MatrixAPIWithArbitraryRoomState = (*ASIntent)(nil) func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { if extra == nil { @@ -121,6 +122,9 @@ func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userI func (as *ASIntent) SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { if eventType == event.StateMember { + if stateKey == "" { + return &mautrix.RespSendEvent{}, nil + } as.fillMemberEvent(ctx, roomID, id.UserID(stateKey), content) } resp, err = as.Matrix.SendStateEvent(ctx, roomID, eventType, stateKey, content, mautrix.ReqSendEvent{Timestamp: ts.UnixMilli()}) diff --git a/bridgev2/matrix/matrix.go b/bridgev2/matrix/matrix.go index 4915c733..24f7c666 100644 --- a/bridgev2/matrix/matrix.go +++ b/bridgev2/matrix/matrix.go @@ -24,7 +24,7 @@ import ( ) func (br *Connector) handleRoomEvent(ctx context.Context, evt *event.Event) { - if evt.Type == event.StateMember && br.Crypto != nil && !br.Bridge.IsGhostMXID(id.UserID(evt.GetStateKey())) { + if !br.BotMode && evt.Type == event.StateMember && br.Crypto != nil && !br.Bridge.IsGhostMXID(id.UserID(evt.GetStateKey())) { br.Crypto.HandleMemberEvent(ctx, evt) } if br.shouldIgnoreEvent(evt) { diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index 11a97d3e..26addac4 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -229,6 +229,9 @@ appservice: # to leave display name/avatar as-is. displayname: $<<.DisplayName>> bridge bot avatar: $<<.NetworkIcon>> + # To use a single bot with per-message profiles instead of an appservice, specify the access token here. + # When set, as_token and hs_token will not be used. + access_token: null # Whether to receive ephemeral events via appservice transactions. ephemeral_events: true diff --git a/bridgev2/matrix/mxmain/main.go b/bridgev2/matrix/mxmain/main.go index 1e8b51d1..5946e0f9 100644 --- a/bridgev2/matrix/mxmain/main.go +++ b/bridgev2/matrix/mxmain/main.go @@ -185,6 +185,10 @@ func (br *BridgeMain) GenerateRegistration() { } else if br.Config.Homeserver.Domain == "example.com" { _, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set") os.Exit(20) + } else if br.Config.AppService.Bot.AccessToken != "" { + _, _ = fmt.Fprintln(os.Stderr, "Generating a registration is not necessary in bot mode") + _, _ = fmt.Fprintln(os.Stderr, "To use appservice mode instead of bot mode, remove appservice -> bot -> access_token") + os.Exit(20) } reg := br.Config.GenerateRegistration() err := reg.Save(br.RegistrationPath) @@ -295,9 +299,9 @@ func (br *BridgeMain) validateConfig() error { return errors.New("homeserver.domain not configured") case !bridgeconfig.AllowedHomeserverSoftware[br.Config.Homeserver.Software]: return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)") - case br.Config.AppService.ASToken == "This value is generated when generating the registration": + case br.Config.AppService.ASToken == "This value is generated when generating the registration" && br.Config.AppService.Bot.AccessToken == "": return errors.New("appservice.as_token not configured. Did you forget to generate the registration? ") - case br.Config.AppService.HSToken == "This value is generated when generating the registration": + case br.Config.AppService.HSToken == "This value is generated when generating the registration" && br.Config.AppService.Bot.AccessToken == "": return errors.New("appservice.hs_token not configured. Did you forget to generate the registration? ") case br.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable": return errors.New("database.uri not configured") @@ -305,6 +309,10 @@ func (br *BridgeMain) validateConfig() error { return errors.New("bridge.permissions not configured") case !strings.Contains(br.Config.AppService.FormatUsername("1234567890"), "1234567890"): return errors.New("username template is missing user ID placeholder") + case br.Config.AppService.Bot.AccessToken != "" && br.Config.Homeserver.Websocket: + return errors.New("appservice websockets cannot be used in single-bot mode") + case br.Config.AppService.Bot.AccessToken != "" && br.Config.Encryption.Appservice: + return errors.New("appservice encryption cannot be used in single-bot mode") default: cfgValidator, ok := br.Connector.(bridgev2.ConfigValidatingNetwork) if ok { diff --git a/bridgev2/matrix/relayintent.go b/bridgev2/matrix/relayintent.go new file mode 100644 index 00000000..375ab411 --- /dev/null +++ b/bridgev2/matrix/relayintent.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "fmt" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type RelayIntent struct { + *ASIntent + ID networkid.UserID + ghost *bridgev2.Ghost +} + +var _ bridgev2.MatrixAPI = (*RelayIntent)(nil) + +func (as *RelayIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType event.Type, content *event.Content, extra *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { + if as.ghost == nil { + var err error + as.ghost, err = as.Connector.Bridge.GetExistingGhostByID(ctx, as.ID) + if err != nil { + return nil, fmt.Errorf("failed to get ghost for relay intent: %w", err) + } + } + msgContent, ok := content.Parsed.(*event.MessageEventContent) + if ok { + msgContent.RemovePerMessageProfileFallback() + if msgContent.NewContent != nil { + msgContent = msgContent.NewContent + } + if msgContent.BeeperPerMessageProfile == nil { + msgContent.BeeperPerMessageProfile = &event.BeeperPerMessageProfile{} + } + pmp := msgContent.BeeperPerMessageProfile + if pmp.ID != "" { + pmp.ID = fmt.Sprintf("%s/%s", as.ID, pmp.ID) + } else { + pmp.ID = string(as.ID) + } + if pmp.Displayname == "" { + pmp.Displayname = as.ghost.Name + } + if pmp.AvatarURL == nil && pmp.AvatarFile == nil { + pmp.AvatarURL = &as.ghost.AvatarMXC + } + msgContent.AddPerMessageProfileFallback() + } else { + content.Raw["com.beeper.per_message_profile"] = &event.BeeperPerMessageProfile{ + ID: string(as.ID), + Displayname: as.ghost.Name, + AvatarURL: &as.ghost.AvatarMXC, + } + } + return as.ASIntent.SendMessage(ctx, roomID, eventType, content, extra) +} + +func (as *RelayIntent) SendState(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, content *event.Content, ts time.Time) (resp *mautrix.RespSendEvent, err error) { + if as.ghost == nil { + as.ghost, err = as.Connector.Bridge.GetExistingGhostByID(ctx, as.ID) + if err != nil { + return nil, fmt.Errorf("failed to get ghost for relay intent: %w", err) + } + } + content.Raw["com.beeper.per_message_profile"] = &event.BeeperPerMessageProfile{ + ID: string(as.ID), + Displayname: as.ghost.Name, + AvatarURL: &as.ghost.AvatarMXC, + } + return as.ASIntent.SendState(ctx, roomID, eventType, stateKey, content, ts) +} + +func (as *RelayIntent) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, ts time.Time) error { + return nil +} + +func (as *RelayIntent) MarkUnread(ctx context.Context, roomID id.RoomID, unread bool) error { + return nil +} + +func (as *RelayIntent) MarkTyping(ctx context.Context, roomID id.RoomID, typingType bridgev2.TypingType, timeout time.Duration) error { + return nil +} + +func (as *RelayIntent) SetDisplayName(ctx context.Context, name string) error { + return nil +} + +func (as *RelayIntent) SetAvatarURL(ctx context.Context, avatarURL id.ContentURIString) error { + return nil +} + +func (as *RelayIntent) SetProfile(ctx context.Context, data any) error { + return nil +} + +func (as *RelayIntent) SetExtraProfileMeta(ctx context.Context, data any) error { + return nil +} + +func (as *RelayIntent) GetMXID() id.UserID { + // TODO make sure this doesn't explode anything + return "" +} + +func (as *RelayIntent) IsDoublePuppet() bool { + return false +} + +func (as *RelayIntent) EnsureJoined(ctx context.Context, roomID id.RoomID, extra ...bridgev2.EnsureJoinedParams) error { + return nil +} + +func (as *RelayIntent) EnsureInvited(ctx context.Context, roomID id.RoomID, userID id.UserID) error { + return fmt.Errorf("can't use EnsureInvited on relay intent") +} + +func (as *RelayIntent) MarkAsDM(ctx context.Context, roomID id.RoomID, withUser id.UserID) error { + return fmt.Errorf("can't use MarkAsDM on relay intent") +} + +func (as *RelayIntent) TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error { + return fmt.Errorf("can't use TagRoom on relay intent") +} + +func (as *RelayIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error { + return fmt.Errorf("can't use MuteRoom on relay intent") +} diff --git a/bridgev2/matrix/relaysync.go b/bridgev2/matrix/relaysync.go new file mode 100644 index 00000000..abc3eb07 --- /dev/null +++ b/bridgev2/matrix/relaysync.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package matrix + +import ( + "context" + "errors" + "os" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (br *Connector) configureRelaySyncer() { + everything := []event.Type{{Type: "*"}} + syncer := mautrix.NewDefaultSyncer() + syncer.FilterJSON = &mautrix.Filter{ + Presence: &mautrix.FilterPart{NotTypes: everything}, + AccountData: &mautrix.FilterPart{NotTypes: everything}, + Room: &mautrix.RoomFilter{ + IncludeLeave: false, + AccountData: &mautrix.FilterPart{NotTypes: everything}, + State: &mautrix.FilterPart{LazyLoadMembers: true}, + }, + } + syncer.ParseEventContent = true + syncer.OnSync(br.Bot.MoveInviteState) + syncer.OnSync(br.Bot.DontProcessOldEvents) + syncer.OnSync(br.Crypto.ProcessSyncResponse) + syncer.OnEvent(func(ctx context.Context, evt *event.Event) { + src := evt.Mautrix.EventSource + if src&(event.SourceState|event.SourceTimeline|event.SourceEphemeral) != 0 { + br.EventProcessor.Dispatch(ctx, evt) + } + }) + br.Bot.Syncer = syncer + br.Bot.Store = &RelaySyncStore{c: br} +} + +func (br *Connector) startRelaySyncer() { + var ctx context.Context + ctx, br.stopBotSync = context.WithCancel(context.Background()) + ctx = br.Log.WithContext(ctx) + br.botSyncDone.Add(1) + defer br.botSyncDone.Done() + err := br.Bot.SyncWithContext(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + br.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Fatal error syncing") + os.Exit(51) + } + br.Log.Info().Msg("Bridge bot syncer stopped without error") +} + +func (br *Connector) stopRelaySyncer() { + if fn := br.stopBotSync; fn != nil { + fn() + br.botSyncDone.Wait() + } +} + +type RelaySyncStore struct { + c *Connector +} + +var _ mautrix.SyncStore = (*RelaySyncStore)(nil) + +func (r *RelaySyncStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { + return nil +} + +func (r *RelaySyncStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { + return "", nil +} + +func makeSyncTokenKey(userID id.UserID) database.Key { + return database.Key("synctoken_" + userID.String()) +} + +func (r *RelaySyncStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { + r.c.Bridge.DB.KV.Set(ctx, makeSyncTokenKey(userID), nextBatchToken) + return nil +} + +func (r *RelaySyncStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { + return r.c.Bridge.DB.KV.Get(ctx, makeSyncTokenKey(userID)), nil +} diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 8ac1258e..d267d378 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -4356,7 +4356,7 @@ func (portal *Portal) sendStateWithIntentOrBot(ctx context.Context, sender Matri sender = portal.Bridge.Bot } resp, err = sender.SendState(ctx, portal.MXID, eventType, stateKey, content, ts) - if errors.Is(err, mautrix.MForbidden) && sender != portal.Bridge.Bot { + if errors.Is(err, mautrix.MForbidden) && sender != portal.Bridge.Bot && sender.GetMXID() != "" { if content.Raw == nil { content.Raw = make(map[string]any) } @@ -4487,13 +4487,13 @@ func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMem if member.PowerLevel != nil { pl.EnsureUserLevel(extraUserID, *member.PowerLevel) } - if intent != nil { + if intent != nil && intent.GetMXID() != "" { // If intent is present along with a user ID, it's the ghost of a logged-in user, // so add it to the functional members list functional = append(functional, intent.GetMXID()) } } - if intent != nil { + if intent != nil && intent.GetMXID() != "" { invite = append(invite, intent.GetMXID()) if member.PowerLevel != nil { pl.EnsureUserLevel(intent.GetMXID(), *member.PowerLevel) @@ -4600,6 +4600,9 @@ func (portal *Portal) syncParticipants( } } syncUser := func(extraUserID id.UserID, member ChatMember, intent MatrixAPI) bool { + if extraUserID == "" { + return false + } if member.Membership == "" { member.Membership = event.MembershipJoin } @@ -4695,6 +4698,9 @@ func (portal *Portal) syncParticipants( return true } syncIntent := func(intent MatrixAPI, member ChatMember) { + if intent == nil { + return + } if !syncUser(intent.GetMXID(), member, intent) { return } @@ -4723,12 +4729,8 @@ func (portal *Portal) syncParticipants( if err != nil { return err } - if intent != nil { - syncIntent(intent, member) - } - if extraUserID != "" { - syncUser(extraUserID, member, nil) - } + syncIntent(intent, member) + syncUser(extraUserID, member, nil) } if powerChanged { _, err = portal.sendStateWithIntentOrBot(ctx, sender, event.StatePowerLevels, "", &event.Content{Parsed: currentPower}, ts) diff --git a/event/state.go b/event/state.go index ace170a5..40f5fe41 100644 --- a/event/state.go +++ b/event/state.go @@ -337,11 +337,11 @@ type ElementFunctionalMembersContent struct { } func (efmc *ElementFunctionalMembersContent) Add(mxid id.UserID) bool { - if slices.Contains(efmc.ServiceMembers, mxid) { - return false + if mxid != "" && !slices.Contains(efmc.ServiceMembers, mxid) { + efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) + return true } - efmc.ServiceMembers = append(efmc.ServiceMembers, mxid) - return true + return false } type PolicyServerPublicKeys struct {