mirror of
https://github.com/mautrix/signal.git
synced 2026-05-14 21:26:54 -04:00
923 lines
32 KiB
Go
923 lines
32 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"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/ptr"
|
|
"go.mau.fi/util/variationselector"
|
|
"google.golang.org/protobuf/proto"
|
|
"maunium.net/go/mautrix/bridgev2"
|
|
"maunium.net/go/mautrix/bridgev2/database"
|
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
|
"maunium.net/go/mautrix/bridgev2/simplevent"
|
|
"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"
|
|
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
|
|
)
|
|
|
|
var (
|
|
_ bridgev2.EditHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.ReactionHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.RedactionHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.TypingHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.RoomNameHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.RoomTopicHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.ChatViewingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.DisappearTimerChangingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.DeleteChatHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.PollHandlingNetworkAPI = (*SignalClient)(nil)
|
|
_ bridgev2.MessageRequestAcceptingNetworkAPI = (*SignalClient)(nil)
|
|
)
|
|
|
|
func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error {
|
|
userID, groupID, err := signalid.ParsePortalID(portalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if groupID != "" {
|
|
result, err := s.Client.SendGroupMessage(ctx, groupID, content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
totalRecipients := len(result.FailedToSendTo) + len(result.SuccessfullySentTo)
|
|
log := zerolog.Ctx(ctx).With().
|
|
Int("total_recipients", totalRecipients).
|
|
Int("failed_to_send_to_count", len(result.FailedToSendTo)).
|
|
Int("successfully_sent_to_count", len(result.SuccessfullySentTo)).
|
|
Logger()
|
|
if len(result.SuccessfullySentTo) == 0 && len(result.FailedToSendTo) == 0 {
|
|
log.Debug().Msg("No successes or failures - Probably sent to myself")
|
|
} else if len(result.SuccessfullySentTo) == 0 {
|
|
log.Error().Msg("Failed to send event to all members of Signal group")
|
|
return errors.New("failed to send to any members of Signal group")
|
|
} else if len(result.SuccessfullySentTo) < totalRecipients {
|
|
if len(result.FailedToSendTo) > 0 {
|
|
log.Warn().Msg("Failed to send event to some members of Signal group")
|
|
} else {
|
|
log.Warn().Msg("Only sent event to some members of Signal group")
|
|
}
|
|
} else {
|
|
log.Debug().Msg("Sent event to all members of Signal group")
|
|
}
|
|
return nil
|
|
} else {
|
|
res := s.Client.SendMessage(ctx, userID, content)
|
|
if !res.WasSuccessful {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func getTimestampForEvent(txnID networkid.RawTransactionID, evt *event.Event, origSender *bridgev2.OrigSender) uint64 {
|
|
if origSender != nil {
|
|
// Relaybot messages are never allowed to set the timestamp
|
|
return uint64(time.Now().UnixMilli())
|
|
}
|
|
if len(txnID) > 0 {
|
|
parsed, err := strconv.ParseUint(string(txnID), 10, 64)
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return uint64(evt.Timestamp)
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) {
|
|
converted, err := s.Main.MsgConv.ToSignal(
|
|
ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.doSendMessage(ctx, msg, converted, &signalid.MessageMetadata{
|
|
ContainsAttachments: len(converted.Attachments) > 0,
|
|
})
|
|
}
|
|
|
|
func (s *SignalClient) doSendMessage(
|
|
ctx context.Context,
|
|
msg *bridgev2.MatrixMessage,
|
|
converted *signalpb.DataMessage,
|
|
meta *signalid.MessageMetadata,
|
|
) (*bridgev2.MatrixMessageResponse, error) {
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
converted.Timestamp = &ts
|
|
if meta == nil {
|
|
meta = &signalid.MessageMetadata{}
|
|
}
|
|
msgID := signalid.MakeMessageID(s.Client.Store.ACI, ts)
|
|
msg.AddPendingToIgnore(networkid.TransactionID(msgID))
|
|
err := s.sendMessage(ctx, msg.Portal.ID, &signalpb.Content{DataMessage: converted})
|
|
if err != nil {
|
|
return nil, bridgev2.WrapErrorInStatus(err).WithSendNotice(true)
|
|
}
|
|
dbMsg := &database.Message{
|
|
ID: msgID,
|
|
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
|
|
Timestamp: time.UnixMilli(int64(ts)),
|
|
Metadata: meta,
|
|
}
|
|
return &bridgev2.MatrixMessageResponse{
|
|
DB: dbMsg,
|
|
RemovePending: networkid.TransactionID(msgID),
|
|
}, nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
|
|
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse target message ID: %w", err)
|
|
} else if msg.EditTarget.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
|
|
return fmt.Errorf("cannot edit other people's messages")
|
|
}
|
|
var replyTo *database.Message
|
|
if msg.EditTarget.ReplyTo.MessageID != "" {
|
|
replyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get message reply target: %w", err)
|
|
}
|
|
}
|
|
converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
converted.Timestamp = &ts
|
|
err = s.sendMessage(ctx, msg.Portal.ID, &signalpb.Content{EditMessage: &signalpb.EditMessage{
|
|
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
|
|
DataMessage: converted,
|
|
}})
|
|
if err != nil {
|
|
return bridgev2.WrapErrorInStatus(err).WithSendNotice(true)
|
|
}
|
|
msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, ts)
|
|
msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0}
|
|
msg.EditTarget.EditCount++
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
|
|
return bridgev2.MatrixReactionPreResponse{
|
|
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
|
|
EmojiID: "",
|
|
Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key),
|
|
}, nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
|
|
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse target message ID: %w", err)
|
|
}
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
wrappedContent := &signalpb.Content{
|
|
DataMessage: &signalpb.DataMessage{
|
|
Timestamp: proto.Uint64(ts),
|
|
RequiredProtocolVersion: proto.Uint32(uint32(signalpb.DataMessage_REACTIONS)),
|
|
Reaction: &signalpb.DataMessage_Reaction{
|
|
Emoji: proto.String(msg.PreHandleResp.Emoji),
|
|
Remove: proto.Bool(false),
|
|
TargetAuthorAciBinary: targetAuthorACI[:],
|
|
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
|
|
},
|
|
},
|
|
}
|
|
err = s.sendMessage(ctx, msg.Portal.ID, wrappedContent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &database.Reaction{}, nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
|
|
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetReaction.MessageID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse target message ID: %w", err)
|
|
}
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
wrappedContent := &signalpb.Content{
|
|
DataMessage: &signalpb.DataMessage{
|
|
Timestamp: proto.Uint64(ts),
|
|
RequiredProtocolVersion: proto.Uint32(uint32(signalpb.DataMessage_REACTIONS)),
|
|
Reaction: &signalpb.DataMessage_Reaction{
|
|
Emoji: proto.String(msg.TargetReaction.Emoji),
|
|
Remove: proto.Bool(true),
|
|
TargetAuthorAciBinary: targetAuthorACI[:],
|
|
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
|
|
},
|
|
},
|
|
}
|
|
err = s.sendMessage(ctx, msg.Portal.ID, wrappedContent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
|
|
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse target message ID: %w", err)
|
|
} else if msg.TargetMessage.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
|
|
return fmt.Errorf("cannot delete other people's messages")
|
|
}
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
wrappedContent := &signalpb.Content{
|
|
DataMessage: &signalpb.DataMessage{
|
|
Timestamp: proto.Uint64(ts),
|
|
Delete: &signalpb.DataMessage_Delete{
|
|
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
|
|
},
|
|
},
|
|
}
|
|
err = s.sendMessage(ctx, msg.Portal.ID, wrappedContent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error {
|
|
if !receipt.ReadUpTo.After(receipt.LastRead) {
|
|
return nil
|
|
}
|
|
if receipt.LastRead.IsZero() {
|
|
receipt.LastRead = receipt.ReadUpTo.Add(-5 * time.Second)
|
|
}
|
|
dbMessages, err := s.Main.Bridge.DB.Message.GetMessagesBetweenTimeQuery(ctx, receipt.Portal.PortalKey, receipt.LastRead, receipt.ReadUpTo)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get messages to mark as read: %w", err)
|
|
} else if len(dbMessages) == 0 {
|
|
return nil
|
|
}
|
|
messagesToRead := map[uuid.UUID][]uint64{}
|
|
for _, msg := range dbMessages {
|
|
userID, timestamp, err := signalid.ParseMessageID(msg.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
messagesToRead[userID] = append(messagesToRead[userID], timestamp)
|
|
}
|
|
zerolog.Ctx(ctx).Debug().
|
|
Any("targets", messagesToRead).
|
|
Msg("Collected read receipt target messages")
|
|
|
|
// TODO send sync message manually containing all read receipts instead of a separate message for each recipient
|
|
|
|
for destination, messages := range messagesToRead {
|
|
// Don't send read receipts for own messages
|
|
if destination == s.Client.Store.ACI {
|
|
continue
|
|
}
|
|
// Don't use portal.sendSignalMessage because we're sending this straight to
|
|
// who sent the original message, not the portal's ChatID
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
result := s.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(destination), signalmeow.ReadReceptMessageForTimestamps(messages))
|
|
cancel()
|
|
if !result.WasSuccessful {
|
|
zerolog.Ctx(ctx).Err(result.Error).
|
|
Stringer("destination", destination).
|
|
Uints64("message_ids", messages).
|
|
Msg("Failed to send read receipt to Signal")
|
|
} else {
|
|
zerolog.Ctx(ctx).Debug().
|
|
Stringer("destination", destination).
|
|
Uints64("message_ids", messages).
|
|
Msg("Sent read receipt to Signal")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2.MatrixTyping) error {
|
|
userID, groupID, err := signalid.ParsePortalID(typing.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
typingMessage := signalmeow.TypingMessage(typing.IsTyping)
|
|
if !userID.IsEmpty() && userID.Type == libsignalgo.ServiceIDTypeACI {
|
|
result := s.Client.SendMessage(ctx, userID, typingMessage)
|
|
if !result.WasSuccessful {
|
|
return result.Error
|
|
}
|
|
} else if groupID != "" {
|
|
_, err = s.Client.SendGroupMessage(ctx, groupID, typingMessage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev2.Portal, gc *signalmeow.GroupChange, postUpdatePortal func()) (bool, error) {
|
|
_, groupID, err := signalid.ParsePortalID(portal.ID)
|
|
if err != nil || groupID == "" {
|
|
return false, err
|
|
}
|
|
gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1
|
|
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if gc.ModifyTitle != nil {
|
|
portal.Name = *gc.ModifyTitle
|
|
portal.NameSet = true
|
|
}
|
|
if gc.ModifyDescription != nil {
|
|
portal.Topic = *gc.ModifyDescription
|
|
portal.TopicSet = true
|
|
}
|
|
if gc.ModifyAvatar != nil {
|
|
portal.AvatarID = makeAvatarPathID(*gc.ModifyAvatar)
|
|
portal.AvatarSet = true
|
|
}
|
|
if postUpdatePortal != nil {
|
|
postUpdatePortal()
|
|
}
|
|
portal.Metadata.(*signalid.PortalMetadata).Revision = revision
|
|
return true, nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
|
|
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
|
|
ModifyTitle: &msg.Content.Name,
|
|
}, nil)
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
|
|
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil || groupID == "" {
|
|
return false, err
|
|
}
|
|
var avatarPath string
|
|
var avatarHash [32]byte
|
|
if msg.Content.URL != "" {
|
|
data, err := s.Main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to download avatar: %w", err)
|
|
}
|
|
avatarHash = sha256.Sum256(data)
|
|
avatarPath, err = s.Client.UploadGroupAvatar(ctx, data, groupID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to reupload avatar: %w", err)
|
|
}
|
|
}
|
|
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
|
|
ModifyAvatar: &avatarPath,
|
|
}, func() {
|
|
msg.Portal.AvatarMXC = msg.Content.URL
|
|
msg.Portal.AvatarHash = avatarHash
|
|
})
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (bool, error) {
|
|
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
|
|
ModifyDescription: &msg.Content.Topic,
|
|
}, nil)
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) {
|
|
if msg.Type.IsSelf && msg.OrigSender != nil {
|
|
return nil, nil
|
|
}
|
|
var targetIntent bridgev2.MatrixAPI
|
|
var targetSignalID libsignalgo.ServiceID
|
|
var err error
|
|
if msg.Portal.RoomType == database.RoomTypeDM {
|
|
switch msg.Type {
|
|
case bridgev2.Invite:
|
|
return nil, fmt.Errorf("cannot invite additional user to dm")
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
targetSignalID, err = signalid.ParseGhostOrUserLoginID(msg.Target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse target signal id: %w", err)
|
|
}
|
|
switch target := msg.Target.(type) {
|
|
case *bridgev2.Ghost:
|
|
targetIntent = target.Intent
|
|
case *bridgev2.UserLogin:
|
|
targetIntent = target.User.DoublePuppet(ctx)
|
|
if targetIntent == nil {
|
|
ghost, err := s.Main.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ghost for user: %w", err)
|
|
}
|
|
targetIntent = ghost.Intent
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("cannot get target intent: unknown type: %T", target)
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("From Membership", string(msg.Type.From)).
|
|
Str("To Membership", string(msg.Type.To)).
|
|
Logger()
|
|
gc := &signalmeow.GroupChange{}
|
|
role := signalmeow.GroupMember_DEFAULT
|
|
if msg.Type.To == event.MembershipInvite || msg.Type == bridgev2.AcceptKnock {
|
|
levels, err := msg.Portal.Bridge.Matrix.GetPowerLevels(ctx, msg.Portal.MXID)
|
|
if err != nil {
|
|
log.Err(err).Msg("Couldn't get power levels")
|
|
if levels.GetUserLevel(targetIntent.GetMXID()) >= moderatorPL {
|
|
role = signalmeow.GroupMember_ADMINISTRATOR
|
|
}
|
|
}
|
|
}
|
|
switch msg.Type {
|
|
case bridgev2.AcceptInvite:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't accept invite for non-ACI service ID")
|
|
}
|
|
gc.PromotePendingMembers = []*signalmeow.PromotePendingMember{{
|
|
ACI: targetSignalID.UUID,
|
|
}}
|
|
case bridgev2.RevokeInvite, bridgev2.RejectInvite:
|
|
gc.DeletePendingMembers = []*libsignalgo.ServiceID{&targetSignalID}
|
|
case bridgev2.Leave, bridgev2.Kick:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't kick non-ACI service ID")
|
|
}
|
|
gc.DeleteMembers = []*uuid.UUID{&targetSignalID.UUID}
|
|
case bridgev2.Invite:
|
|
if targetSignalID.Type == libsignalgo.ServiceIDTypeACI {
|
|
gc.AddMembers = []*signalmeow.AddMember{{
|
|
GroupMember: signalmeow.GroupMember{
|
|
ACI: targetSignalID.UUID,
|
|
Role: role,
|
|
},
|
|
}}
|
|
} else {
|
|
gc.AddPendingMembers = []*signalmeow.PendingMember{{
|
|
ServiceID: targetSignalID,
|
|
Role: role,
|
|
AddedByUserID: s.Client.Store.ACI,
|
|
Timestamp: uint64(msg.Event.Timestamp),
|
|
}}
|
|
}
|
|
// TODO: joining and knocking requires a way to obtain the invite link
|
|
// because the joining/knocking member doesn't have the GroupMasterKey yet
|
|
// case bridgev2.Join:
|
|
// gc.AddMembers = []*signalmeow.AddMember{{
|
|
// GroupMember: signalmeow.GroupMember{
|
|
// ACI: targetSignalID,
|
|
// Role: role,
|
|
// },
|
|
// JoinFromInviteLink: true,
|
|
// }}
|
|
// case bridgev2.Knock:
|
|
// gc.AddRequestingMembers = []*signalmeow.RequestingMember{{
|
|
// ACI: targetSignalID,
|
|
// Timestamp: uint64(time.Now().UnixMilli()),
|
|
// }}
|
|
case bridgev2.AcceptKnock:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't accept knock from non-ACI service ID")
|
|
}
|
|
gc.PromoteRequestingMembers = []*signalmeow.RoleMember{{
|
|
ACI: targetSignalID.UUID,
|
|
Role: role,
|
|
}}
|
|
case bridgev2.RetractKnock, bridgev2.RejectKnock:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't reject knock from non-ACI service ID")
|
|
}
|
|
gc.DeleteRequestingMembers = []*uuid.UUID{&targetSignalID.UUID}
|
|
case bridgev2.BanKnocked, bridgev2.BanInvited, bridgev2.BanJoined, bridgev2.BanLeft:
|
|
gc.AddBannedMembers = []*signalmeow.BannedMember{{
|
|
ServiceID: targetSignalID,
|
|
Timestamp: uint64(time.Now().UnixMilli()),
|
|
}}
|
|
switch msg.Type {
|
|
case bridgev2.BanJoined:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't ban joined non-ACI service ID")
|
|
}
|
|
gc.DeleteMembers = []*uuid.UUID{&targetSignalID.UUID}
|
|
case bridgev2.BanInvited:
|
|
gc.DeletePendingMembers = []*libsignalgo.ServiceID{&targetSignalID}
|
|
case bridgev2.BanKnocked:
|
|
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
|
|
return nil, fmt.Errorf("can't ban knocked non-ACI service ID")
|
|
}
|
|
gc.DeleteRequestingMembers = []*uuid.UUID{&targetSignalID.UUID}
|
|
}
|
|
case bridgev2.Unban:
|
|
gc.DeleteBannedMembers = []*libsignalgo.ServiceID{&targetSignalID}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported membership change: %s -> %s", msg.Type.From, msg.Type.To)
|
|
}
|
|
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil || groupID == "" {
|
|
return nil, err
|
|
}
|
|
gc.Revision = msg.Portal.Metadata.(*signalid.PortalMetadata).Revision + 1
|
|
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if msg.Type == bridgev2.Invite && targetSignalID.Type != libsignalgo.ServiceIDTypePNI {
|
|
err = targetIntent.EnsureJoined(ctx, msg.Portal.MXID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision
|
|
return nil, nil
|
|
}
|
|
|
|
func plToRole(pl int) signalmeow.GroupMemberRole {
|
|
if pl >= moderatorPL {
|
|
return signalmeow.GroupMember_ADMINISTRATOR
|
|
} else {
|
|
return signalmeow.GroupMember_DEFAULT
|
|
}
|
|
}
|
|
|
|
func plToAccessControl(pl int) *signalmeow.AccessControl {
|
|
var accessControl signalmeow.AccessControl
|
|
if pl >= moderatorPL {
|
|
accessControl = signalmeow.AccessControl_ADMINISTRATOR
|
|
} else {
|
|
accessControl = signalmeow.AccessControl_MEMBER
|
|
}
|
|
return &accessControl
|
|
}
|
|
|
|
func hasAdminChanged(plc *bridgev2.SinglePowerLevelChange) bool {
|
|
if plc == nil {
|
|
return false
|
|
}
|
|
return (plc.NewLevel < moderatorPL) != (plc.OrigLevel < moderatorPL)
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixPowerLevels(ctx context.Context, msg *bridgev2.MatrixPowerLevelChange) (bool, error) {
|
|
if msg.Portal.RoomType == database.RoomTypeDM {
|
|
return false, nil
|
|
}
|
|
gc := &signalmeow.GroupChange{}
|
|
for _, plc := range msg.Users {
|
|
if !hasAdminChanged(&plc.SinglePowerLevelChange) {
|
|
continue
|
|
}
|
|
serviceID, err := signalid.ParseGhostOrUserLoginID(plc.Target)
|
|
if err != nil || serviceID.Type != libsignalgo.ServiceIDTypeACI {
|
|
continue
|
|
}
|
|
gc.ModifyMemberRoles = append(gc.ModifyMemberRoles, &signalmeow.RoleMember{
|
|
ACI: serviceID.UUID,
|
|
Role: plToRole(plc.NewLevel),
|
|
})
|
|
}
|
|
if hasAdminChanged(msg.EventsDefault) {
|
|
announcementsOnly := msg.EventsDefault.NewLevel >= moderatorPL
|
|
gc.ModifyAnnouncementsOnly = &announcementsOnly
|
|
}
|
|
if hasAdminChanged(msg.StateDefault) {
|
|
gc.ModifyAttributesAccess = plToAccessControl(msg.StateDefault.NewLevel)
|
|
}
|
|
if hasAdminChanged(msg.Invite) {
|
|
gc.ModifyMemberAccess = plToAccessControl(msg.Invite.NewLevel)
|
|
}
|
|
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil || groupID == "" {
|
|
return false, err
|
|
}
|
|
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision
|
|
return true, nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
|
|
if msg.Portal == nil {
|
|
return nil
|
|
}
|
|
|
|
// Sync the other users ghost in DMs
|
|
if msg.Portal.OtherUserID != "" {
|
|
ghost, err := s.Main.Bridge.GetExistingGhostByID(ctx, msg.Portal.OtherUserID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get ghost for sync: %w", err)
|
|
} else if ghost == nil {
|
|
zerolog.Ctx(ctx).Warn().
|
|
Str("other_user_id", string(msg.Portal.OtherUserID)).
|
|
Msg("No ghost found for other user in portal")
|
|
} else {
|
|
meta := ghost.Metadata.(*signalid.GhostMetadata)
|
|
if meta.ProfileFetchedAt.Time.Add(5 * time.Minute).Before(time.Now()) {
|
|
// Reset, but don't save, portal last sync time for immediate sync now
|
|
meta.ProfileFetchedAt.Time = time.Time{}
|
|
info, err := s.GetUserInfoWithRefreshAfter(ctx, ghost, 5*time.Minute)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user info: %w", err)
|
|
}
|
|
ghost.UpdateInfo(ctx, info)
|
|
}
|
|
}
|
|
}
|
|
|
|
// always resync the portal if its stale
|
|
portalMeta := msg.Portal.Metadata.(*signalid.PortalMetadata)
|
|
if portalMeta.LastSync.Add(24 * time.Hour).Before(time.Now()) {
|
|
s.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
|
|
EventMeta: simplevent.EventMeta{
|
|
Type: bridgev2.RemoteEventChatResync,
|
|
PortalKey: msg.Portal.PortalKey,
|
|
},
|
|
GetChatInfoFunc: s.GetChatInfo,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
|
|
if msg.Content.Type != event.DisappearingTypeAfterRead && msg.Content.Timer.Duration != 0 {
|
|
return false, fmt.Errorf("unsupported disappearing timer type: %s", msg.Content.Type)
|
|
}
|
|
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
newSetting := database.DisappearingSetting{
|
|
Type: event.DisappearingTypeAfterRead,
|
|
Timer: msg.Content.Timer.Duration,
|
|
}
|
|
if newSetting.Timer == 0 {
|
|
newSetting.Type = event.DisappearingTypeNone
|
|
}
|
|
if groupID != "" {
|
|
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
|
|
ModifyDisappearingMessagesDuration: ptr.Ptr(uint32(msg.Content.Timer.Seconds())),
|
|
}, func() {
|
|
msg.Portal.Disappear = newSetting
|
|
})
|
|
} else {
|
|
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
|
|
res := s.Client.SendMessage(ctx, userID, &signalpb.Content{
|
|
DataMessage: &signalpb.DataMessage{
|
|
Timestamp: ptr.Ptr(ts),
|
|
Flags: ptr.Ptr(uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE)),
|
|
ExpireTimer: ptr.Ptr(uint32(msg.Content.Timer.Seconds())),
|
|
},
|
|
})
|
|
if !res.WasSuccessful {
|
|
return false, res.Error
|
|
}
|
|
msg.Portal.Disappear = newSetting
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error {
|
|
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse portal ID: %w", err)
|
|
}
|
|
|
|
if msg.Content.FromMessageRequest {
|
|
// TODO block and delete support?
|
|
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_DELETE)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send message request delete sync: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build ConversationIdentifier based on portal type
|
|
var conversationID *signalpb.ConversationIdentifier
|
|
if groupID == "" {
|
|
conversationID = &signalpb.ConversationIdentifier{
|
|
Identifier: &signalpb.ConversationIdentifier_ThreadServiceIdBinary{
|
|
ThreadServiceIdBinary: userID.Bytes(),
|
|
},
|
|
}
|
|
} else {
|
|
gid, err := groupID.Bytes()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse group ID: %w", err)
|
|
}
|
|
conversationID = &signalpb.ConversationIdentifier{
|
|
Identifier: &signalpb.ConversationIdentifier_ThreadGroupId{
|
|
ThreadGroupId: gid[:],
|
|
},
|
|
}
|
|
}
|
|
|
|
// Retrieve most recent messages from the portal
|
|
var mostRecentMessages []*signalpb.AddressableMessage
|
|
dbMessages, err := s.Main.Bridge.DB.Message.GetMessagesBetweenTimeQuery(
|
|
ctx,
|
|
msg.Portal.PortalKey,
|
|
time.Now().Add(-30*24*time.Hour), // Last 30 days
|
|
time.Now(),
|
|
)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get recent messages for conversation delete")
|
|
} else if len(dbMessages) > 0 {
|
|
// Limit to the 5 most recent messages overall
|
|
limit := 5
|
|
startIdx := 0
|
|
if len(dbMessages) > limit {
|
|
startIdx = len(dbMessages) - limit
|
|
}
|
|
|
|
// Create AddressableMessage for most recent messages
|
|
for _, dbMsg := range dbMessages[startIdx:] {
|
|
senderACI, timestamp, err := signalid.ParseMessageID(dbMsg.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
mostRecentMessages = append(mostRecentMessages, &signalpb.AddressableMessage{
|
|
Author: &signalpb.AddressableMessage_AuthorServiceIdBinary{
|
|
AuthorServiceIdBinary: senderACI[:],
|
|
},
|
|
SentTimestamp: proto.Uint64(timestamp),
|
|
})
|
|
}
|
|
}
|
|
|
|
recipientID := s.Client.Store.ACIServiceID()
|
|
// Send DeleteForMe sync message to self
|
|
result := s.Client.SendMessage(ctx, recipientID, &signalpb.Content{
|
|
SyncMessage: &signalpb.SyncMessage{
|
|
DeleteForMe: &signalpb.SyncMessage_DeleteForMe{
|
|
ConversationDeletes: []*signalpb.SyncMessage_DeleteForMe_ConversationDelete{{
|
|
Conversation: conversationID,
|
|
MostRecentMessages: mostRecentMessages,
|
|
IsFullDelete: proto.Bool(true),
|
|
}},
|
|
},
|
|
},
|
|
})
|
|
|
|
zerolog.Ctx(ctx).Debug().
|
|
Str("portal_id", string(msg.Portal.ID)).
|
|
Int("recent_messages_count", len(mostRecentMessages)).
|
|
Msg("Sent conversation deletion to Signal")
|
|
|
|
if !result.WasSuccessful {
|
|
return fmt.Errorf("failed to send delete conversation sync message: %w %s %s", result.Error, userID, groupID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
|
|
optionNames := make([]string, len(msg.Content.PollStart.Answers))
|
|
optionIDs := make([]string, len(msg.Content.PollStart.Answers))
|
|
for i, option := range msg.Content.PollStart.Answers {
|
|
optionNames[i] = option.Text
|
|
optionIDs[i] = option.ID
|
|
}
|
|
converted := &signalpb.DataMessage{
|
|
PollCreate: &signalpb.DataMessage_PollCreate{
|
|
Question: ptr.Ptr(msg.Content.PollStart.Question.Text),
|
|
AllowMultiple: ptr.Ptr(msg.Content.PollStart.MaxSelections != 1),
|
|
Options: optionNames,
|
|
},
|
|
RequiredProtocolVersion: ptr.Ptr(uint32(signalpb.DataMessage_POLLS)),
|
|
}
|
|
return s.doSendMessage(ctx, &msg.MatrixMessage, converted, &signalid.MessageMetadata{
|
|
MatrixPollOptionIDs: optionIDs,
|
|
})
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (*bridgev2.MatrixMessageResponse, error) {
|
|
senderACI, msgTS, err := signalid.ParseMessageID(msg.VoteTo.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mxOptions := msg.VoteTo.Metadata.(*signalid.MessageMetadata).MatrixPollOptionIDs
|
|
optionIndexes := make([]uint32, len(msg.Content.Response.Answers))
|
|
for i, answer := range msg.Content.Response.Answers {
|
|
if idx := slices.Index(mxOptions, answer); idx >= 0 {
|
|
optionIndexes[i] = uint32(idx)
|
|
} else if idx, err = strconv.Atoi(answer); err == nil && idx >= 0 {
|
|
optionIndexes[i] = uint32(idx)
|
|
} else {
|
|
return nil, fmt.Errorf("unknown poll answer ID: %s", answer)
|
|
}
|
|
}
|
|
converted := &signalpb.DataMessage{
|
|
PollVote: &signalpb.DataMessage_PollVote{
|
|
TargetAuthorAciBinary: senderACI[:],
|
|
TargetSentTimestamp: &msgTS,
|
|
OptionIndexes: optionIndexes,
|
|
VoteCount: proto.Uint32(1), // TODO
|
|
},
|
|
RequiredProtocolVersion: proto.Uint32(0),
|
|
}
|
|
return s.doSendMessage(ctx, &msg.MatrixMessage, converted, nil)
|
|
}
|
|
|
|
func (s *SignalClient) syncMessageRequestResponse(
|
|
ctx context.Context,
|
|
portal *bridgev2.Portal,
|
|
respType signalpb.SyncMessage_MessageRequestResponse_Type,
|
|
) error {
|
|
userID, groupID, err := signalid.ParsePortalID(portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accept := &signalpb.SyncMessage_MessageRequestResponse{
|
|
Type: respType.Enum(),
|
|
}
|
|
if groupID != "" {
|
|
gidBytes, err := groupID.Bytes()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse group ID: %w", err)
|
|
}
|
|
accept.GroupId = gidBytes[:]
|
|
} else if userID.Type == libsignalgo.ServiceIDTypeACI {
|
|
accept.ThreadAciBinary = userID.UUID[:]
|
|
} else {
|
|
return fmt.Errorf("invalid portal ID for message request response: %s", portal.ID)
|
|
}
|
|
res := s.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(s.Client.Store.ACI), &signalpb.Content{
|
|
SyncMessage: &signalpb.SyncMessage{
|
|
MessageRequestResponse: accept,
|
|
},
|
|
})
|
|
if !res.WasSuccessful {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SignalClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg *bridgev2.MatrixAcceptMessageRequest) error {
|
|
userID, _, err := signalid.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_ACCEPT)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync message request acceptance: %w", err)
|
|
}
|
|
if userID.Type == libsignalgo.ServiceIDTypeACI {
|
|
profileKey, err := s.Client.ProfileKeyForSignalID(ctx, s.Client.Store.ACI)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get own profile key: %w", err)
|
|
}
|
|
var pniSig *signalpb.PniSignatureMessage
|
|
if s.Client.Store.AccountRecord.GetPhoneNumberSharingMode() == signalpb.AccountRecord_EVERYBODY {
|
|
sig, err := s.Client.Store.PNIIdentityKeyPair.SignAlternateIdentity(s.Client.Store.ACIIdentityKeyPair.GetIdentityKey())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate PNI signature: %w", err)
|
|
}
|
|
pniSig = &signalpb.PniSignatureMessage{
|
|
Pni: s.Client.Store.PNI[:],
|
|
Signature: sig,
|
|
}
|
|
}
|
|
res := s.Client.SendMessage(ctx, userID, &signalpb.Content{
|
|
DataMessage: &signalpb.DataMessage{
|
|
Flags: proto.Uint32(uint32(signalpb.DataMessage_PROFILE_KEY_UPDATE)),
|
|
ProfileKey: profileKey.Slice(),
|
|
Timestamp: proto.Uint64(getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)),
|
|
|
|
RequiredProtocolVersion: proto.Uint32(0),
|
|
},
|
|
PniSignatureMessage: pniSig,
|
|
})
|
|
if !res.WasSuccessful {
|
|
return fmt.Errorf("failed to share profile key to accept message request: %w", res.Error)
|
|
}
|
|
// TODO send read receipts too?
|
|
}
|
|
return nil
|
|
}
|