From a9ff1443f70678599bb74a2c941de23593827bb2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 22 Sep 2025 16:05:42 +0300 Subject: [PATCH] bridgev2: add interface for deleting chats from Matrix Closes #408 --- bridgev2/errors.go | 2 + bridgev2/matrix/connector.go | 1 + bridgev2/networkinterface.go | 9 ++++ bridgev2/portal.go | 83 +++++++++++++++++++++++++++++++----- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/bridgev2/errors.go b/bridgev2/errors.go index 52bebe81..694224f1 100644 --- a/bridgev2/errors.go +++ b/bridgev2/errors.go @@ -44,6 +44,7 @@ var ( ErrNoPortal error = WrapErrorInStatus(errors.New("room is not a portal")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringReactionFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring reaction event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrIgnoringPollFromRelayedUser error = WrapErrorInStatus(errors.New("ignoring poll event from relayed user")).WithIsCertain(true).WithSendNotice(false) + ErrIgnoringDeleteChatRelayedUser error = WrapErrorInStatus(errors.New("ignoring delete chat event from relayed user")).WithIsCertain(true).WithSendNotice(false) ErrEditsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support edits")).WithIsCertain(true).WithErrorAsMessage() ErrEditsNotSupportedInPortal error = WrapErrorInStatus(errors.New("edits are not allowed in this chat")).WithIsCertain(true).WithErrorAsMessage() ErrCaptionsNotAllowed error = WrapErrorInStatus(errors.New("captions are not supported here")).WithIsCertain(true).WithErrorAsMessage() @@ -65,6 +66,7 @@ var ( ErrMediaReuploadFailed error = WrapErrorInStatus(errors.New("failed to reupload media")).WithMessage("failed to reupload media").WithIsCertain(true).WithSendNotice(true) ErrMediaConvertFailed error = WrapErrorInStatus(errors.New("failed to convert media")).WithMessage("failed to convert media").WithIsCertain(true).WithSendNotice(true) ErrMembershipNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group membership")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) + ErrDeleteChatNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support deleting chats")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrPowerLevelsNotSupported error = WrapErrorInStatus(errors.New("this bridge does not support changing group power levels")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(false) ErrRemoteEchoTimeout = WrapErrorInStatus(errors.New("remote echo timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) ErrRemoteAckTimeout = WrapErrorInStatus(errors.New("remote ack timed out")).WithIsCertain(false).WithSendNotice(true).WithErrorReason(event.MessageStatusTooOld) diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index ab1764dd..3dd9ae1a 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -148,6 +148,7 @@ func (br *Connector) Init(bridge *bridgev2.Bridge) { br.EventProcessor.On(event.StateTopic, br.handleRoomEvent) br.EventProcessor.On(event.StateTombstone, br.handleRoomEvent) br.EventProcessor.On(event.StateBeeperDisappearingTimer, br.handleRoomEvent) + br.EventProcessor.On(event.BeeperDeleteChat, br.handleRoomEvent) br.EventProcessor.On(event.EphemeralEventReceipt, br.handleEphemeralEvent) br.EventProcessor.On(event.EphemeralEventTyping, br.handleEphemeralEvent) br.Bot = br.AS.BotIntent() diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index fa87086a..8dffbb34 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -697,6 +697,14 @@ type DisappearTimerChangingNetworkAPI interface { HandleMatrixDisappearingTimer(ctx context.Context, msg *MatrixDisappearingTimer) (bool, error) } +// DeleteChatHandlingNetworkAPI is an optional interface that network connectors +// can implement to delete a chat from the remote network. +type DeleteChatHandlingNetworkAPI interface { + NetworkAPI + // HandleMatrixDeleteChat is called when the user explicitly deletes a chat. + HandleMatrixDeleteChat(ctx context.Context, msg *MatrixDeleteChat) error +} + type ResolveIdentifierResponse struct { // Ghost is the ghost of the user that the identifier resolves to. // This field should be set whenever possible. However, it is not required, @@ -1380,6 +1388,7 @@ type MatrixViewingChat struct { Portal *Portal } +type MatrixDeleteChat = MatrixEventBase[*event.BeeperChatDeleteEventContent] type MatrixMarkedUnread = MatrixRoomMeta[*event.MarkedUnreadEventContent] type MatrixMute = MatrixRoomMeta[*event.BeeperMuteEventContent] type MatrixRoomTag = MatrixRoomMeta[*event.TagEventContent] diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 575edfb8..f53691fa 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -706,6 +706,8 @@ func (portal *Portal) handleMatrixEvent(ctx context.Context, sender *User, evt * return portal.handleMatrixMembership(ctx, login, origSender, evt) case event.StatePowerLevels: return portal.handleMatrixPowerLevels(ctx, login, origSender, evt) + case event.BeeperDeleteChat: + return portal.handleMatrixDeleteChat(ctx, login, origSender, evt) default: return EventHandlingResultIgnored } @@ -1622,6 +1624,58 @@ func (portal *Portal) getTargetUser(ctx context.Context, userID id.UserID) (Ghos } } +func (portal *Portal) handleMatrixDeleteChat( + ctx context.Context, + sender *UserLogin, + origSender *OrigSender, + evt *event.Event, +) EventHandlingResult { + if origSender != nil { + return EventHandlingResultFailed.WithMSSError(ErrIgnoringDeleteChatRelayedUser) + } + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.BeeperChatDeleteEventContent) + if !ok { + log.Error().Type("content_type", evt.Content.Parsed).Msg("Unexpected parsed content type") + return EventHandlingResultFailed.WithMSSError(fmt.Errorf("%w: %T", ErrUnexpectedParsedContentType, evt.Content.Parsed)) + } + api, ok := sender.Client.(DeleteChatHandlingNetworkAPI) + if !ok { + return EventHandlingResultIgnored.WithMSSError(ErrDeleteChatNotSupported) + } + err := api.HandleMatrixDeleteChat(ctx, &MatrixDeleteChat{ + Event: evt, + Content: content, + Portal: portal, + }) + if err != nil { + log.Err(err).Msg("Failed to handle Matrix chat delete") + return EventHandlingResultFailed.WithMSSError(err) + } + if portal.Receiver == "" { + _, others, err := portal.findOtherLogins(ctx, sender) + if err != nil { + log.Err(err).Msg("Failed to check if portal has other logins") + return EventHandlingResultFailed.WithError(err) + } else if len(others) > 0 { + log.Debug().Msg("Not deleting portal after chat delete as other logins are present") + return EventHandlingResultSuccess + } + } + err = portal.Delete(ctx) + if err != nil { + log.Err(err).Msg("Failed to delete portal from database") + return EventHandlingResultFailed.WithMSSError(err) + } + err = portal.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false) + if err != nil { + log.Err(err).Msg("Failed to delete Matrix room") + return EventHandlingResultFailed.WithMSSError(err) + } + // No MSS here as the portal was deleted + return EventHandlingResultSuccess +} + func (portal *Portal) handleMatrixMembership( ctx context.Context, sender *UserLogin, @@ -3160,11 +3214,11 @@ func (portal *Portal) handleRemoteMessageRemove(ctx context.Context, source *Use onlyForMeProvider, ok := evt.(RemoteDeleteOnlyForMe) onlyForMe := ok && onlyForMeProvider.DeleteOnlyForMe() if onlyForMe && portal.Receiver == "" { - logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + _, others, err := portal.findOtherLogins(ctx, source) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") return EventHandlingResultFailed.WithError(err) - } else if len(logins) > 1 { + } else if len(others) > 0 { log.Debug().Msg("Ignoring delete for me event in portal with multiple logins") return EventHandlingResultIgnored } @@ -3413,22 +3467,29 @@ func (portal *Portal) handleRemoteChatResync(ctx context.Context, source *UserLo return EventHandlingResultSuccess } +func (portal *Portal) findOtherLogins(ctx context.Context, source *UserLogin) (ownUP *database.UserPortal, others []*database.UserPortal, err error) { + others, err = portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + if err != nil { + return + } + others = slices.DeleteFunc(others, func(up *database.UserPortal) bool { + if up.LoginID == source.ID { + ownUP = up + return true + } + return false + }) + return +} + func (portal *Portal) handleRemoteChatDelete(ctx context.Context, source *UserLogin, evt RemoteChatDelete) EventHandlingResult { log := zerolog.Ctx(ctx) if portal.Receiver == "" && evt.DeleteOnlyForMe() { - logins, err := portal.Bridge.DB.UserPortal.GetAllInPortal(ctx, portal.PortalKey) + ownUP, logins, err := portal.findOtherLogins(ctx, source) if err != nil { log.Err(err).Msg("Failed to check if portal has other logins") return EventHandlingResultFailed.WithError(err) } - var ownUP *database.UserPortal - logins = slices.DeleteFunc(logins, func(up *database.UserPortal) bool { - if up.LoginID == source.ID { - ownUP = up - return true - } - return false - }) if len(logins) > 0 { log.Debug().Msg("Not deleting portal with other logins in remote chat delete event") if ownUP != nil {