mirror of
https://github.com/mautrix/signal.git
synced 2026-05-15 05:36:53 -04:00
441 lines
15 KiB
Go
441 lines
15 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
package connector
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/jsontime"
|
|
"go.mau.fi/util/ptr"
|
|
"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/libsignalgo"
|
|
"go.mau.fi/mautrix-signal/pkg/signalid"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
|
|
)
|
|
|
|
var defaultPL = 0
|
|
var moderatorPL = 50
|
|
|
|
func roleToPL(role signalmeow.GroupMemberRole) *int {
|
|
switch role {
|
|
case signalmeow.GroupMember_ADMINISTRATOR:
|
|
return &moderatorPL
|
|
case signalmeow.GroupMember_DEFAULT:
|
|
fallthrough
|
|
default:
|
|
return &defaultPL
|
|
}
|
|
}
|
|
|
|
func applyAnnouncementsOnly(plc *bridgev2.PowerLevelOverrides, announcementsOnly bool) {
|
|
if announcementsOnly {
|
|
plc.EventsDefault = &moderatorPL
|
|
} else {
|
|
plc.EventsDefault = &defaultPL
|
|
}
|
|
}
|
|
|
|
func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess signalmeow.AccessControl) {
|
|
attributePL := defaultPL
|
|
if attributeAccess == signalmeow.AccessControl_ADMINISTRATOR {
|
|
attributePL = moderatorPL
|
|
}
|
|
plc.Events[event.StateRoomName] = attributePL
|
|
plc.Events[event.StateRoomAvatar] = attributePL
|
|
plc.Events[event.StateTopic] = attributePL
|
|
plc.Events[event.StateBeeperDisappearingTimer] = attributePL
|
|
}
|
|
|
|
func applyMembersAccess(plc *bridgev2.PowerLevelOverrides, memberAccess signalmeow.AccessControl) {
|
|
if memberAccess == signalmeow.AccessControl_ADMINISTRATOR {
|
|
plc.Invite = &moderatorPL
|
|
} else {
|
|
plc.Invite = &defaultPL
|
|
}
|
|
}
|
|
|
|
func inviteLinkToJoinRule(inviteLinkAccess signalmeow.AccessControl) event.JoinRule {
|
|
switch inviteLinkAccess {
|
|
case signalmeow.AccessControl_UNSATISFIABLE:
|
|
return event.JoinRuleInvite
|
|
case signalmeow.AccessControl_ADMINISTRATOR:
|
|
return event.JoinRuleKnock
|
|
case signalmeow.AccessControl_ANY:
|
|
// TODO allow public portals?
|
|
publicPortals := false
|
|
if publicPortals {
|
|
return event.JoinRulePublic
|
|
} else {
|
|
return event.JoinRuleKnock
|
|
}
|
|
default:
|
|
return event.JoinRuleInvite
|
|
}
|
|
}
|
|
|
|
func (s *SignalClient) getGroupInfo(ctx context.Context, groupID types.GroupIdentifier, minRevision uint32, backupChat *store.BackupChat) (*bridgev2.ChatInfo, error) {
|
|
groupInfo, _, err := s.Client.RetrieveGroupByID(ctx, groupID, minRevision)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve group by id: %w", err)
|
|
}
|
|
return s.wrapGroupInfo(ctx, groupInfo, backupChat)
|
|
}
|
|
|
|
func (s *SignalClient) wrapGroupInfo(ctx context.Context, groupInfo *signalmeow.Group, backupChat *store.BackupChat) (*bridgev2.ChatInfo, error) {
|
|
members := &bridgev2.ChatMemberList{
|
|
IsFull: true,
|
|
MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(groupInfo.Members)+len(groupInfo.PendingMembers)+len(groupInfo.RequestingMembers)+len(groupInfo.BannedMembers)),
|
|
PowerLevels: &bridgev2.PowerLevelOverrides{
|
|
Events: map[event.Type]int{
|
|
event.StatePowerLevels: moderatorPL,
|
|
},
|
|
},
|
|
ExcludeChangesFromTimeline: true,
|
|
}
|
|
applyAnnouncementsOnly(members.PowerLevels, groupInfo.AnnouncementsOnly)
|
|
joinRule := event.JoinRuleInvite
|
|
if groupInfo.AccessControl != nil {
|
|
applyAttributesAccess(members.PowerLevels, groupInfo.AccessControl.Attributes)
|
|
applyMembersAccess(members.PowerLevels, groupInfo.AccessControl.Members)
|
|
joinRule = inviteLinkToJoinRule(groupInfo.AccessControl.AddFromInviteLink)
|
|
}
|
|
for _, member := range groupInfo.RequestingMembers {
|
|
members.MemberMap.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
Membership: event.MembershipKnock,
|
|
})
|
|
}
|
|
for _, member := range groupInfo.PendingMembers {
|
|
s.addChatMemberWithACIQuery(ctx, members.MemberMap, member.ServiceID, bridgev2.ChatMember{
|
|
PowerLevel: roleToPL(member.Role),
|
|
Membership: event.MembershipInvite,
|
|
MemberSender: s.makeEventSender(member.AddedByUserID),
|
|
})
|
|
}
|
|
for _, member := range groupInfo.Members {
|
|
members.MemberMap.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
PowerLevel: roleToPL(member.Role),
|
|
Membership: event.MembershipJoin,
|
|
})
|
|
}
|
|
for _, member := range groupInfo.BannedMembers {
|
|
s.addChatMemberWithACIQuery(ctx, members.MemberMap, member.ServiceID, bridgev2.ChatMember{
|
|
Membership: event.MembershipBan,
|
|
})
|
|
}
|
|
if backupChat == nil {
|
|
var err error
|
|
// TODO allow using backup chat for data too instead of asking server?
|
|
backupChat, err = s.Client.Store.BackupStore.GetBackupChatByGroupID(ctx, groupInfo.GroupIdentifier)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get backup chat for group")
|
|
}
|
|
}
|
|
avatar, err := s.makeGroupAvatar(ctx, groupInfo.GroupIdentifier, &groupInfo.AvatarPath, groupInfo.GroupMasterKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to make group avatar: %w", err)
|
|
}
|
|
return &bridgev2.ChatInfo{
|
|
Name: &groupInfo.Title,
|
|
Topic: &groupInfo.Description,
|
|
Avatar: avatar,
|
|
Disappear: &database.DisappearingSetting{
|
|
Type: event.DisappearingTypeAfterRead,
|
|
Timer: time.Duration(groupInfo.DisappearingMessagesDuration) * time.Second,
|
|
},
|
|
Members: members,
|
|
Type: ptr.Ptr(database.RoomTypeDefault),
|
|
JoinRule: &event.JoinRulesEventContent{JoinRule: joinRule},
|
|
ExtraUpdates: bridgev2.MergeExtraUpdaters(makeRevisionUpdater(groupInfo.Revision), updatePortalSyncMeta),
|
|
CanBackfill: backupChat != nil,
|
|
|
|
ExcludeChangesFromTimeline: true,
|
|
}, nil
|
|
}
|
|
|
|
func addMemberToMap(mc map[networkid.UserID]bridgev2.ChatMember, member bridgev2.ChatMember) {
|
|
mc[member.EventSender.Sender] = member
|
|
}
|
|
|
|
func updatePortalSyncMeta(ctx context.Context, portal *bridgev2.Portal) bool {
|
|
meta := portal.Metadata.(*signalid.PortalMetadata)
|
|
meta.LastSync = jsontime.UnixNow()
|
|
return true
|
|
}
|
|
|
|
func (s *SignalClient) makeGroupAvatar(ctx context.Context, groupID types.GroupIdentifier, path *string, groupMasterKey types.SerializedGroupMasterKey) (*bridgev2.Avatar, error) {
|
|
if path == nil {
|
|
return nil, nil
|
|
}
|
|
avatar := &bridgev2.Avatar{
|
|
ID: makeAvatarPathID(*path),
|
|
Remove: *path == "",
|
|
}
|
|
if s.Main.MsgConv.DirectMedia {
|
|
userID, err := signalid.ParseUserLoginID(s.UserLogin.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse user login ID: %w", err)
|
|
}
|
|
groupIDBytes, err := groupID.Bytes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get group id bytes: %w", err)
|
|
}
|
|
mediaID, err := signalid.DirectMediaGroupAvatar{
|
|
UserID: userID,
|
|
GroupID: groupIDBytes,
|
|
GroupAvatarPath: *path,
|
|
}.AsMediaID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
avatar.MXC, err = s.Main.Bridge.Matrix.GenerateContentURI(ctx, mediaID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
avatar.Hash = signalid.HashMediaID(mediaID)
|
|
} else {
|
|
avatar.Get = func(ctx context.Context) ([]byte, error) {
|
|
return s.Client.DownloadGroupAvatar(ctx, *path, groupMasterKey)
|
|
}
|
|
}
|
|
return avatar, nil
|
|
}
|
|
|
|
func makeRevisionUpdater(rev uint32) func(ctx context.Context, portal *bridgev2.Portal) bool {
|
|
return func(ctx context.Context, portal *bridgev2.Portal) bool {
|
|
meta := portal.Metadata.(*signalid.PortalMetadata)
|
|
if meta.Revision < rev {
|
|
meta.Revision = rev
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID types.GroupIdentifier, rev uint32, groupChange *signalmeow.GroupChange) (*bridgev2.ChatInfoChange, error) {
|
|
avatar, err := s.makeGroupAvatar(ctx, groupID, groupChange.ModifyAvatar, groupChange.GroupMasterKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ic := &bridgev2.ChatInfoChange{
|
|
ChatInfo: &bridgev2.ChatInfo{
|
|
ExtraUpdates: makeRevisionUpdater(rev),
|
|
Name: groupChange.ModifyTitle,
|
|
Topic: groupChange.ModifyDescription,
|
|
Avatar: avatar,
|
|
},
|
|
}
|
|
if groupChange.ModifyDisappearingMessagesDuration != nil {
|
|
ic.ChatInfo.Disappear = &database.DisappearingSetting{
|
|
Type: event.DisappearingTypeAfterRead,
|
|
Timer: time.Duration(*groupChange.ModifyDisappearingMessagesDuration) * time.Second,
|
|
}
|
|
}
|
|
|
|
var pls *bridgev2.PowerLevelOverrides
|
|
if groupChange.ModifyAnnouncementsOnly != nil ||
|
|
groupChange.ModifyAttributesAccess != nil ||
|
|
groupChange.ModifyMemberAccess != nil {
|
|
pls = &bridgev2.PowerLevelOverrides{Events: make(map[event.Type]int)}
|
|
if groupChange.ModifyAnnouncementsOnly != nil {
|
|
applyAnnouncementsOnly(pls, *groupChange.ModifyAnnouncementsOnly)
|
|
}
|
|
if groupChange.ModifyAttributesAccess != nil {
|
|
applyAttributesAccess(pls, *groupChange.ModifyAttributesAccess)
|
|
}
|
|
if groupChange.ModifyMemberAccess != nil {
|
|
applyMembersAccess(pls, *groupChange.ModifyMemberAccess)
|
|
}
|
|
}
|
|
if groupChange.ModifyAddFromInviteLinkAccess != nil {
|
|
ic.ChatInfo.JoinRule = &event.JoinRulesEventContent{
|
|
JoinRule: inviteLinkToJoinRule(*groupChange.ModifyAddFromInviteLinkAccess),
|
|
}
|
|
}
|
|
mc := make(bridgev2.ChatMemberMap)
|
|
for _, member := range groupChange.AddPendingMembers {
|
|
s.addChatMemberWithACIQuery(ctx, mc, member.ServiceID, bridgev2.ChatMember{
|
|
PowerLevel: roleToPL(member.Role),
|
|
Membership: event.MembershipInvite,
|
|
PrevMembership: event.MembershipLeave,
|
|
MemberSender: s.makeEventSender(member.AddedByUserID),
|
|
})
|
|
}
|
|
for _, member := range groupChange.AddRequestingMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
Membership: event.MembershipKnock,
|
|
})
|
|
}
|
|
for _, memberServiceID := range groupChange.DeletePendingMembers {
|
|
s.addChatMemberWithACIQuery(ctx, mc, *memberServiceID, bridgev2.ChatMember{
|
|
Membership: event.MembershipLeave,
|
|
PrevMembership: event.MembershipInvite,
|
|
})
|
|
}
|
|
for _, memberACI := range groupChange.DeleteRequestingMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(*memberACI),
|
|
Membership: event.MembershipLeave,
|
|
PrevMembership: event.MembershipKnock,
|
|
})
|
|
}
|
|
for _, memberACI := range groupChange.DeleteMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(*memberACI),
|
|
Membership: event.MembershipLeave,
|
|
PrevMembership: event.MembershipJoin,
|
|
})
|
|
}
|
|
for _, memberServiceID := range groupChange.DeleteBannedMembers {
|
|
s.addChatMemberWithACIQuery(ctx, mc, *memberServiceID, bridgev2.ChatMember{
|
|
Membership: event.MembershipLeave,
|
|
PrevMembership: event.MembershipBan,
|
|
})
|
|
}
|
|
for _, member := range groupChange.AddBannedMembers {
|
|
s.addChatMemberWithACIQuery(ctx, mc, member.ServiceID, bridgev2.ChatMember{
|
|
Membership: event.MembershipBan,
|
|
})
|
|
}
|
|
for _, member := range groupChange.PromotePendingMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
Membership: event.MembershipJoin,
|
|
PrevMembership: event.MembershipInvite,
|
|
})
|
|
}
|
|
for _, member := range groupChange.PromotePendingPniAciMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
Membership: event.MembershipJoin,
|
|
})
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makePNIEventSender(member.PNI),
|
|
Membership: event.MembershipLeave,
|
|
PrevMembership: event.MembershipInvite,
|
|
MemberEventExtra: map[string]any{
|
|
"com.beeper.exclude_from_timeline": true,
|
|
},
|
|
})
|
|
}
|
|
for _, member := range groupChange.PromoteRequestingMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
Membership: event.MembershipJoin,
|
|
PrevMembership: event.MembershipKnock,
|
|
})
|
|
}
|
|
for _, member := range groupChange.AddMembers {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
PowerLevel: roleToPL(member.Role),
|
|
Membership: event.MembershipJoin,
|
|
})
|
|
}
|
|
for _, member := range groupChange.ModifyMemberRoles {
|
|
mc.Set(bridgev2.ChatMember{
|
|
EventSender: s.makeEventSender(member.ACI),
|
|
PowerLevel: roleToPL(member.Role),
|
|
Membership: event.MembershipJoin,
|
|
})
|
|
}
|
|
if len(mc) > 0 || pls != nil {
|
|
ic.MemberChanges = &bridgev2.ChatMemberList{MemberMap: mc, PowerLevels: pls}
|
|
}
|
|
return ic, nil
|
|
}
|
|
|
|
func (s *SignalClient) addChatMemberWithACIQuery(
|
|
ctx context.Context, mc bridgev2.ChatMemberMap, serviceID libsignalgo.ServiceID, member bridgev2.ChatMember,
|
|
) {
|
|
member.EventSender = s.makeEventSenderFromServiceID(serviceID)
|
|
mc.Set(member)
|
|
if aci := s.tryResolvePNItoLoggedInACI(ctx, serviceID); aci != nil {
|
|
member.EventSender = s.makeEventSender(*aci)
|
|
mc.Add(member)
|
|
}
|
|
}
|
|
|
|
func (s *SignalClient) tryResolvePNItoLoggedInACI(ctx context.Context, serviceID libsignalgo.ServiceID) *uuid.UUID {
|
|
if serviceID.Type != libsignalgo.ServiceIDTypePNI {
|
|
return nil
|
|
} else if serviceID.UUID == s.Client.Store.PNI {
|
|
return &s.Client.Store.ACI
|
|
} else if s.Main.Bridge.Config.SplitPortals {
|
|
// When split portals is enabled, we don't care about anyone else's logins
|
|
return nil
|
|
} else if device, err := s.Client.Store.DeviceStore.DeviceByPNI(ctx, serviceID.UUID); err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ACI for PNI")
|
|
return nil
|
|
} else if device == nil {
|
|
return nil
|
|
} else {
|
|
return &device.ACI
|
|
}
|
|
}
|
|
|
|
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, nil)
|
|
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")
|
|
s.catchUpGroup(ctx, portal, 0, toRevision, ts)
|
|
return
|
|
}
|
|
for _, gc := range groupChanges {
|
|
log.Debug().Uint32("current_rev", gc.GroupChange.Revision).Msg("Processing group change")
|
|
chatInfoChange, err := s.groupChangeToChatInfoChange(ctx, types.GroupIdentifier(portal.ID), gc.GroupChange.Revision, gc.GroupChange)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to convert group info")
|
|
} else {
|
|
portal.ProcessChatInfoChange(ctx, s.makeEventSenderFromServiceID(gc.GroupChange.SourceServiceID), s.UserLogin, chatInfoChange, time.UnixMilli(int64(ts)))
|
|
}
|
|
if gc.GroupChange.Revision == toRevision {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|