diff --git a/go.mod b/go.mod index 19843dc8..7edae3df 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 golang.org/x/net v0.26.0 google.golang.org/protobuf v1.34.2 - maunium.net/go/mautrix v0.19.0-beta.1.0.20240627083250-e25578d435a2 + maunium.net/go/mautrix v0.19.0-beta.1.0.20240627155110-35f8d837b5f5 nhooyr.io/websocket v1.8.11 ) diff --git a/go.sum b/go.sum index f73fa347..476c3d95 100644 --- a/go.sum +++ b/go.sum @@ -93,7 +93,7 @@ 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/mautrix v0.19.0-beta.1.0.20240627083250-e25578d435a2 h1:HnP1dmvCVO/V22BdJre56Vu1IaEtMRUt86AD/1rmyME= -maunium.net/go/mautrix v0.19.0-beta.1.0.20240627083250-e25578d435a2/go.mod h1:eu/C1dTewrW7yiFNiCKGm4zuWJANyt7zPjaY5g3f3r4= +maunium.net/go/mautrix v0.19.0-beta.1.0.20240627155110-35f8d837b5f5 h1:dJBtUNpc9G0IKq1uRU6kZetBmgMtcmh1F/sMiyDpbDc= +maunium.net/go/mautrix v0.19.0-beta.1.0.20240627155110-35f8d837b5f5/go.mod h1:eu/C1dTewrW7yiFNiCKGm4zuWJANyt7zPjaY5g3f3r4= nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index f0f06f30..aa5c7c84 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -53,30 +53,7 @@ func (s *SignalClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) return nil, err } if groupID != "" { - groupInfo, err := s.Client.RetrieveGroupByID(ctx, groupID, 0) - if err != nil { - return nil, err - } - isDM := false - isSpace := false - members := make([]networkid.UserID, len(groupInfo.Members)) - for i, member := range groupInfo.Members { - members[i] = makeUserID(member.ACI) - } - return &bridgev2.PortalInfo{ - Name: &groupInfo.Title, - Topic: &groupInfo.Description, - Avatar: &bridgev2.Avatar{ - ID: makeAvatarPathID(groupInfo.AvatarPath), - Get: func(ctx context.Context) ([]byte, error) { - return s.Client.DownloadGroupAvatar(ctx, groupInfo) - }, - Remove: groupInfo.AvatarPath == "", - }, - Members: members, - IsDirectChat: &isDM, - IsSpace: &isSpace, - }, nil + return s.getGroupInfo(ctx, groupID, 0) } else { aci, pni := serviceIDToACIAndPNI(userID) contact, err := s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil) diff --git a/pkg/connector/groupinfo.go b/pkg/connector/groupinfo.go new file mode 100644 index 00000000..e4364120 --- /dev/null +++ b/pkg/connector/groupinfo.go @@ -0,0 +1,135 @@ +// mautrix-signal - A Matrix-Signal puppeting bridge. +// Copyright (C) 2024 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 connector + +import ( + "context" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-signal/pkg/signalmeow" + "go.mau.fi/mautrix-signal/pkg/signalmeow/types" +) + +func (s *SignalClient) getGroupInfo(ctx context.Context, groupID types.GroupIdentifier, minRevision uint32) (*bridgev2.PortalInfo, error) { + groupInfo, err := s.Client.RetrieveGroupByID(ctx, groupID, minRevision) + if err != nil { + return nil, err + } + isDM := false + isSpace := false + members := make([]networkid.UserID, len(groupInfo.Members)) + for i, member := range groupInfo.Members { + members[i] = makeUserID(member.ACI) + } + return &bridgev2.PortalInfo{ + Name: &groupInfo.Title, + Topic: &groupInfo.Description, + Avatar: s.makeGroupAvatar(groupInfo), + Disappear: &database.DisappearingSetting{ + Type: database.DisappearingTypeAfterRead, + Timer: time.Duration(groupInfo.DisappearingMessagesDuration) * time.Second, + }, + Members: members, + IsDirectChat: &isDM, + IsSpace: &isSpace, + ExtraUpdates: makeRevisionUpdater(groupInfo.Revision), + }, nil +} + +func (s *SignalClient) makeGroupAvatar(meta signalmeow.GroupAvatarMeta) *bridgev2.Avatar { + path := meta.GetAvatarPath() + if path == nil { + return nil + } + return &bridgev2.Avatar{ + ID: makeAvatarPathID(*path), + Get: func(ctx context.Context) ([]byte, error) { + return s.Client.DownloadGroupAvatar(ctx, meta) + }, + Remove: *path == "", + } +} + +func makeRevisionUpdater(rev uint32) func(ctx context.Context, portal *bridgev2.Portal) bool { + return func(ctx context.Context, portal *bridgev2.Portal) bool { + currentRev, _ := database.GetNumberFromMap[uint32](portal.Metadata.Extra, "revision") + if currentRev < rev { + portal.Metadata.Extra["revision"] = rev + return true + } + return false + } +} + +func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, rev uint32, groupChange *signalmeow.GroupChange) *bridgev2.ChatInfoChange { + ic := &bridgev2.ChatInfoChange{ + PortalInfo: &bridgev2.PortalInfo{ + ExtraUpdates: makeRevisionUpdater(rev), + Name: groupChange.ModifyTitle, + Topic: groupChange.ModifyDescription, + Avatar: s.makeGroupAvatar(groupChange), + }, + } + if groupChange.ModifyDisappearingMessagesDuration != nil { + ic.PortalInfo.Disappear = &database.DisappearingSetting{ + Type: database.DisappearingTypeAfterRead, + Timer: time.Duration(*groupChange.ModifyDisappearingMessagesDuration) * time.Second, + } + } + // TODO handle member/permission/etc changes + return ic +} + +func (s *SignalClient) catchUpGroup(ctx context.Context, portal *bridgev2.Portal, fromRevision, toRevision uint32, ts uint64) { + if fromRevision >= toRevision { + return + } + log := zerolog.Ctx(ctx).With(). + Str("action", "catch up group changes"). + Uint32("from_revision", fromRevision). + Uint32("to_revision", toRevision). + Logger() + if fromRevision == 0 { + log.Info().Msg("Syncing full group info") + info, err := s.getGroupInfo(ctx, types.GroupIdentifier(portal.ID), toRevision) + if err != nil { + log.Err(err).Msg("Failed to get group info") + } else { + portal.UpdateInfo(ctx, info, s.UserLogin, nil, time.Time{}) + } + } else { + log.Info().Msg("Syncing missed group changes") + groupChanges, err := s.Client.GetGroupHistoryPage(ctx, types.GroupIdentifier(portal.ID), fromRevision, false) + if err != nil { + log.Err(err).Msg("Failed to get group history page") + return + } + for _, gc := range groupChanges { + log.Debug().Uint32("current_rev", gc.GroupChange.Revision).Msg("Processing group change") + chatInfoChange := s.groupChangeToChatInfoChange(ctx, gc.GroupChange.Revision, gc.GroupChange) + portal.ProcessChatInfoChange(ctx, s.makeEventSender(gc.GroupChange.SourceACI), s.UserLogin, chatInfoChange, time.UnixMilli(int64(ts))) + if gc.GroupChange.Revision == toRevision { + break + } + } + } +} diff --git a/pkg/connector/handlesignal.go b/pkg/connector/handlesignal.go index 5d10716e..768b69c6 100644 --- a/pkg/connector/handlesignal.go +++ b/pkg/connector/handlesignal.go @@ -23,12 +23,10 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" - "go.mau.fi/util/exfmt" "go.mau.fi/util/exzerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-signal/pkg/signalmeow/events" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" @@ -64,6 +62,8 @@ var ( _ bridgev2.RemoteReactionRemove = (*Bv2ChatEvent)(nil) _ bridgev2.RemoteMessageRemove = (*Bv2ChatEvent)(nil) _ bridgev2.RemoteTyping = (*Bv2ChatEvent)(nil) + _ bridgev2.RemotePreHandler = (*Bv2ChatEvent)(nil) + _ bridgev2.RemoteChatInfoChange = (*Bv2ChatEvent)(nil) ) func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType { @@ -82,6 +82,8 @@ func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType { return bridgev2.RemoteEventReaction case innerEvt.Delete != nil: return bridgev2.RemoteEventMessageRemove + case innerEvt.GetGroupV2().GetGroupChange() != nil: + return bridgev2.RemoteEventChatInfoChange } case *signalpb.EditMessage: return bridgev2.RemoteEventEdit @@ -91,6 +93,34 @@ func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType { return bridgev2.RemoteEventUnknown } +func (evt *Bv2ChatEvent) GetChatInfoChange(ctx context.Context) (*bridgev2.ChatInfoChange, error) { + dm, _ := evt.Event.(*signalpb.DataMessage) + gv2 := dm.GetGroupV2() + if gv2 == nil || gv2.GroupChange == nil { + return nil, fmt.Errorf("GetChatInfoChange() called for non-GroupChange event") + } + groupChange, err := evt.s.Client.DecryptGroupChange(ctx, gv2) + if err != nil { + return nil, fmt.Errorf("failed to decrypt group change: %w", err) + } + return evt.s.groupChangeToChatInfoChange(ctx, gv2.GetRevision(), groupChange), nil +} + +func (evt *Bv2ChatEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal) { + dataMsg, ok := evt.Event.(*signalpb.DataMessage) + if !ok || dataMsg.GroupV2 == nil { + return + } + portalRev, _ := database.GetNumberFromMap[uint32](portal.Metadata.Extra, "revision") + if evt.Info.GroupRevision > portalRev { + toRevision := evt.Info.GroupRevision + if dataMsg.GetGroupV2().GetGroupChange() != nil { + toRevision-- + } + evt.s.catchUpGroup(ctx, portal, portalRev, toRevision, dataMsg.GetTimestamp()) + } +} + func (evt *Bv2ChatEvent) GetTimeout() time.Duration { if evt.Event.(*signalpb.TypingMessage).GetAction() == signalpb.TypingMessage_STARTED { return 15 * time.Second @@ -104,7 +134,7 @@ func (evt *Bv2ChatEvent) GetPortalKey() networkid.PortalKey { } func (evt *Bv2ChatEvent) ShouldCreatePortal() bool { - return evt.GetType() == bridgev2.RemoteEventMessage + return evt.GetType() == bridgev2.RemoteEventMessage || evt.GetType() == bridgev2.RemoteEventChatInfoChange } func (evt *Bv2ChatEvent) AddLogContext(c zerolog.Context) zerolog.Context { @@ -231,28 +261,11 @@ func (evt *Bv2ChatEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Po Type: database.DisappearingTypeAfterRead, Timer: time.Duration(converted.DisappearIn) * time.Second, } + dataMsgTS := time.UnixMilli(int64(dataMsg.GetTimestamp())) if evt.Info.Sender == evt.s.Client.Store.ACI { - disappear.DisappearAt = time.UnixMilli(int64(dataMsg.GetTimestamp())).Add(disappear.Timer) - } - if portal.Metadata.DisappearTimer != disappear.Timer { - portal.Metadata.DisappearType = disappear.Type - portal.Metadata.DisappearTimer = disappear.Timer - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal metadata after implicitly updating disappearing timer") - } else { - zerolog.Ctx(ctx).Debug().Dur("new_timer", portal.Metadata.DisappearTimer).Msg("Implicitly updated disappearing timer in portal") - } - _, err = portal.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ - Parsed: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", exfmt.Duration(disappear.Timer)), - }, - }, time.UnixMilli(int64(dataMsg.GetTimestamp()))) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send notice about disappearing message timer changing implicitly") - } + disappear.DisappearAt = dataMsgTS.Add(disappear.Timer) } + portal.UpdateDisappearingSetting(ctx, disappear, nil, dataMsgTS, true, true) } return &bridgev2.ConvertedMessage{ ReplyTo: replyTo,