diff --git a/bridgev2/bridgeconfig/config.go b/bridgev2/bridgeconfig/config.go index 13ec738c..01819945 100644 --- a/bridgev2/bridgeconfig/config.go +++ b/bridgev2/bridgeconfig/config.go @@ -77,6 +77,7 @@ type BridgeConfig struct { DeduplicateMatrixMessages bool `yaml:"deduplicate_matrix_messages"` CrossRoomReplies bool `yaml:"cross_room_replies"` OutgoingMessageReID bool `yaml:"outgoing_message_re_id"` + RevertFailedStateChanges bool `yaml:"revert_failed_state_changes"` CleanupOnLogout CleanupOnLogouts `yaml:"cleanup_on_logout"` Relay RelayConfig `yaml:"relay"` Permissions PermissionConfig `yaml:"permissions"` diff --git a/bridgev2/bridgeconfig/upgrade.go b/bridgev2/bridgeconfig/upgrade.go index 6533338f..be8a8f96 100644 --- a/bridgev2/bridgeconfig/upgrade.go +++ b/bridgev2/bridgeconfig/upgrade.go @@ -40,6 +40,7 @@ func doUpgrade(helper up.Helper) { helper.Copy(up.Bool, "bridge", "mute_only_on_create") helper.Copy(up.Bool, "bridge", "deduplicate_matrix_messages") helper.Copy(up.Bool, "bridge", "cross_room_replies") + helper.Copy(up.Bool, "bridge", "revert_failed_state_changes") helper.Copy(up.Bool, "bridge", "cleanup_on_logout", "enabled") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "private") helper.Copy(up.Str, "bridge", "cleanup_on_logout", "manual", "relayed") diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 76668a99..a06f30ed 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -54,6 +54,7 @@ var ( ErrReactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support reactions")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrPollsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support polls")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrRoomMetadataNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing room metadata")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) + ErrRoomMetadataNotAllowed error = WrapErrorInStatus(errors.New("changes are not allowed here")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false).WithErrorReason(event.MessageStatusUnsupported) ErrRedactionsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting messages")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported) ErrUnexpectedParsedContentType error = WrapErrorInStatus(errors.New("unexpected parsed content type")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) ErrInvalidStateKey error = WrapErrorInStatus(errors.New("room metadata state key is unset or non-empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(false) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index 64b5d6c7..edd98045 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -361,6 +361,7 @@ func (br *Connector) ensureConnection(ctx context.Context) { *br.AS.SpecVersions = *versions br.Capabilities.AutoJoinInvites = br.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) br.Capabilities.BatchSending = br.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) + br.Capabilities.ArbitraryMemberChange = br.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryMemberChange) break } } diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index ab59a582..27892fb6 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -90,8 +90,8 @@ func (as *ASIntent) SendMessage(ctx context.Context, roomID id.RoomID, eventType } func (as *ASIntent) fillMemberEvent(ctx context.Context, roomID id.RoomID, userID id.UserID, content *event.Content) { - targetContent := content.Parsed.(*event.MemberEventContent) - if targetContent.Displayname != "" || targetContent.AvatarURL != "" { + targetContent, ok := content.Parsed.(*event.MemberEventContent) + if !ok || targetContent.Displayname != "" || targetContent.AvatarURL != "" { return } memberContent, err := as.Matrix.StateStore.TryGetMember(ctx, roomID, userID) diff --git a/bridgev2/matrix/mxmain/example-config.yaml b/bridgev2/matrix/mxmain/example-config.yaml index d8634028..aeb5b7db 100644 --- a/bridgev2/matrix/mxmain/example-config.yaml +++ b/bridgev2/matrix/mxmain/example-config.yaml @@ -47,6 +47,8 @@ bridge: # Should cross-room reply metadata be bridged? # Most Matrix clients don't support this and servers may reject such messages too. cross_room_replies: false + # If a state event fails to bridge, should the bridge revert any state changes made by that event? + revert_failed_state_changes: false # What should be done to portal rooms when a user logs out or is logged out? # Permitted values: diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 6fa5360c..e8489dc1 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -23,8 +23,9 @@ import ( ) type MatrixCapabilities struct { - AutoJoinInvites bool - BatchSending bool + AutoJoinInvites bool + BatchSending bool + ArbitraryMemberChange bool } type MatrixConnector interface { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 4943ab00..67199ada 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -520,6 +520,9 @@ func (portal *Portal) handleSingleEvent(ctx context.Context, rawEvt any, doneCal portal.sendSuccessStatus(ctx, evt.evt, 0, "") } } + if res.Error != nil && evt.evt.StateKey != nil { + portal.revertRoomMeta(ctx, evt.evt) + } case *portalRemoteEvent: res = portal.handleRemoteEvent(ctx, evt.source, evt.evtType, evt.evt) case *portalCreateEvent: @@ -1562,9 +1565,13 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( if evt.StateKey == nil || *evt.StateKey != "" { return EventHandlingResultFailed.WithMSSError(ErrInvalidStateKey) } + //caps := sender.Client.GetCapabilities(ctx, portal) + //if stateCap, ok := caps.State[evt.Type.Type]; !ok || stateCap.Level <= event.CapLevelUnsupported { + // return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%s %w", evt.Type.Type, ErrRoomMetadataNotAllowed)) + //} api, ok := sender.Client.(APIType) if !ok { - return EventHandlingResultIgnored.WithMSSError(ErrRoomMetadataNotSupported) + return EventHandlingResultIgnored.WithMSSError(fmt.Errorf("%w of type %s", ErrRoomMetadataNotSupported, evt.Type)) } log := zerolog.Ctx(ctx) content, ok := evt.Content.Parsed.(ContentType) @@ -1598,7 +1605,6 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( return EventHandlingResultIgnored } if !sender.Client.GetCapabilities(ctx, portal).DisappearingTimer.Supports(typedContent) { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) return EventHandlingResultFailed.WithMSSError(ErrDisappearingTimerUnsupported) } } @@ -1621,9 +1627,6 @@ func handleMatrixRoomMeta[APIType any, ContentType any]( }) if err != nil { log.Err(err).Msg("Failed to handle Matrix room metadata") - if evt.Type == event.StateBeeperDisappearingTimer { - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), false) - } return EventHandlingResultFailed.WithMSSError(err) } if changed { @@ -3891,7 +3894,7 @@ func (portal *Portal) updateName( } portal.Name = name portal.NameSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, + ctx, sender, ts, event.StateRoomName, "", &event.RoomNameEventContent{Name: name}, excludeFromTimeline, nil, ) return true } @@ -3904,7 +3907,7 @@ func (portal *Portal) updateTopic( } portal.Topic = topic portal.TopicSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, + ctx, sender, ts, event.StateTopic, "", &event.TopicEventContent{Topic: topic}, excludeFromTimeline, nil, ) return true } @@ -3935,7 +3938,7 @@ func (portal *Portal) updateAvatar( portal.AvatarHash = newHash } portal.AvatarSet = portal.sendRoomMeta( - ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, + ctx, sender, ts, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, excludeFromTimeline, nil, ) return true } @@ -4003,8 +4006,8 @@ func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { return } stateKey, bridgeInfo := portal.getBridgeInfo() - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false) - portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBridge, stateKey, &bridgeInfo, false, nil) + portal.sendRoomMeta(ctx, nil, time.Now(), event.StateHalfShotBridge, stateKey, &bridgeInfo, false, nil) } func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, implicit bool) bool { @@ -4026,7 +4029,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, Str("old_id", portal.CapState.ID). Str("new_id", capID). Msg("Sending new room capability event") - success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false) + success := portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperRoomFeatures, portal.getBridgeInfoStateKey(), caps, false, nil) if !success { return false } @@ -4037,7 +4040,7 @@ func (portal *Portal) UpdateCapabilities(ctx context.Context, source *UserLogin, } if caps.DisappearingTimer != nil && !portal.CapState.Flags.Has(database.CapStateFlagDisappearingTimerSet) { zerolog.Ctx(ctx).Debug().Msg("Disappearing timer capability was added, sending disappearing timer state event") - success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true) + success = portal.sendRoomMeta(ctx, nil, time.Now(), event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) if !success { return false } @@ -4076,11 +4079,14 @@ func (portal *Portal) sendRoomMeta( stateKey string, content any, excludeFromTimeline bool, + extra map[string]any, ) bool { if portal.MXID == "" { return false } - extra := make(map[string]any) + if extra == nil { + extra = make(map[string]any) + } if excludeFromTimeline { extra["com.beeper.exclude_from_timeline"] = true } @@ -4106,6 +4112,46 @@ func (portal *Portal) sendRoomMeta( return true } +func (portal *Portal) revertRoomMeta(ctx context.Context, evt *event.Event) { + if !portal.Bridge.Config.RevertFailedStateChanges { + return + } + if evt.GetStateKey() != "" && evt.Type != event.StateMember { + return + } + switch evt.Type { + case event.StateRoomName: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomName, "", &event.RoomNameEventContent{Name: portal.Name}, true, nil) + case event.StateRoomAvatar: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateRoomAvatar, "", &event.RoomAvatarEventContent{URL: portal.AvatarMXC}, true, nil) + case event.StateTopic: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateTopic, "", &event.TopicEventContent{Topic: portal.Topic}, true, nil) + case event.StateBeeperDisappearingTimer: + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateBeeperDisappearingTimer, "", portal.Disappear.ToEventContent(), true, nil) + case event.StateMember: + var prevContent *event.MemberEventContent + var extra map[string]any + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent = evt.Unsigned.PrevContent.AsMember() + newContent := evt.Content.AsMember() + if prevContent.Membership == newContent.Membership { + return + } + extra = evt.Unsigned.PrevContent.Raw + } else { + prevContent = &event.MemberEventContent{Membership: event.MembershipLeave} + } + if portal.Bridge.Matrix.GetCapabilities().ArbitraryMemberChange { + if extra == nil { + extra = make(map[string]any) + } + extra["com.beeper.member_rollback"] = true + portal.sendRoomMeta(ctx, nil, time.Time{}, event.StateMember, evt.GetStateKey(), prevContent, true, extra) + } + } +} + func (portal *Portal) getInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { if members == nil { invite = []id.UserID{source.UserMXID} @@ -4490,6 +4536,7 @@ func (portal *Portal) UpdateDisappearingSetting( "", setting.ToEventContent(), opts.ExcludeFromTimeline, + nil, ) if !opts.SendNotice { @@ -4618,7 +4665,7 @@ func (portal *Portal) UpdateInfo(ctx context.Context, info *ChatInfo, source *Us } if info.JoinRule != nil { // TODO change detection instead of spamming this every time? - portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline) + portal.sendRoomMeta(ctx, sender, ts, event.StateJoinRules, "", info.JoinRule, info.ExcludeChangesFromTimeline, nil) } if info.Type != nil && portal.RoomType != *info.Type { if portal.MXID != "" && (*info.Type == database.RoomTypeSpace || portal.RoomType == database.RoomTypeSpace) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index d9373eb6..749ee389 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -289,8 +289,12 @@ func (portal *PortalInternals) SendStateWithIntentOrBot(ctx context.Context, sen return (*Portal)(portal).sendStateWithIntentOrBot(ctx, sender, eventType, stateKey, content, ts) } -func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool) bool { - return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline) +func (portal *PortalInternals) SendRoomMeta(ctx context.Context, sender MatrixAPI, ts time.Time, eventType event.Type, stateKey string, content any, excludeFromTimeline bool, extra map[string]any) bool { + return (*Portal)(portal).sendRoomMeta(ctx, sender, ts, eventType, stateKey, content, excludeFromTimeline, extra) +} + +func (portal *PortalInternals) RevertRoomMeta(ctx context.Context, evt *event.Event) { + (*Portal)(portal).revertRoomMeta(ctx, evt) } func (portal *PortalInternals) GetInitialMemberList(ctx context.Context, members *ChatMemberList, source *UserLogin, pl *event.PowerLevelsEventContent) (invite, functional []id.UserID, err error) { diff --git a/versions.go b/versions.go index 0392532e..8c1c49aa 100644 --- a/versions.go +++ b/versions.go @@ -70,13 +70,14 @@ var ( FeatureUnstableProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133"} FeatureArbitraryProfileFields = UnstableFeature{UnstableFlag: "uk.tcpip.msc4133.stable", SpecVersion: SpecV116} - BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} - BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} - BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} - BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} - BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} - BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} - BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} + BeeperFeatureHungry = UnstableFeature{UnstableFlag: "com.beeper.hungry"} + BeeperFeatureBatchSending = UnstableFeature{UnstableFlag: "com.beeper.batch_sending"} + BeeperFeatureRoomYeeting = UnstableFeature{UnstableFlag: "com.beeper.room_yeeting"} + BeeperFeatureAutojoinInvites = UnstableFeature{UnstableFlag: "com.beeper.room_create_autojoin_invites"} + BeeperFeatureArbitraryProfileMeta = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_profile_meta"} + BeeperFeatureAccountDataMute = UnstableFeature{UnstableFlag: "com.beeper.account_data_mute"} + BeeperFeatureInboxState = UnstableFeature{UnstableFlag: "com.beeper.inbox_state"} + BeeperFeatureArbitraryMemberChange = UnstableFeature{UnstableFlag: "com.beeper.arbitrary_member_change"} ) func (versions *RespVersions) Supports(feature UnstableFeature) bool {