mirror of
https://github.com/mautrix/signal.git
synced 2025-03-14 14:15:36 +00:00
1696 lines
55 KiB
Go
1696 lines
55 KiB
Go
// mautrix-signal - A Matrix-signal puppeting bridge.
|
|
// Copyright (C) 2023 Scott Weber, 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 main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/jsontime"
|
|
"go.mau.fi/util/variationselector"
|
|
"google.golang.org/protobuf/proto"
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/appservice"
|
|
"maunium.net/go/mautrix/bridge"
|
|
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
|
"maunium.net/go/mautrix/bridge/status"
|
|
"maunium.net/go/mautrix/crypto/attachment"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
|
|
"go.mau.fi/mautrix-signal/database"
|
|
"go.mau.fi/mautrix-signal/msgconv"
|
|
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
|
|
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
|
|
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
|
|
)
|
|
|
|
type portalSignalMessage struct {
|
|
evt *events.ChatEvent
|
|
user *User
|
|
}
|
|
|
|
type portalMatrixMessage struct {
|
|
evt *event.Event
|
|
user *User
|
|
}
|
|
|
|
type Portal struct {
|
|
*database.Portal
|
|
|
|
MsgConv *msgconv.MessageConverter
|
|
|
|
bridge *SignalBridge
|
|
log zerolog.Logger
|
|
|
|
roomCreateLock sync.Mutex
|
|
encryptLock sync.Mutex
|
|
|
|
signalMessages chan portalSignalMessage
|
|
matrixMessages chan portalMatrixMessage
|
|
|
|
currentlyTyping []id.UserID
|
|
currentlyTypingLock sync.Mutex
|
|
|
|
latestReadTimestamp uint64 // Cache the latest read timestamp to avoid unnecessary read receipts
|
|
|
|
relayUser *User
|
|
}
|
|
|
|
const recentMessageBufferSize = 32
|
|
|
|
func init() {
|
|
event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{})
|
|
event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{})
|
|
}
|
|
|
|
// ** Interfaces that Portal implements **
|
|
|
|
var _ bridge.Portal = (*Portal)(nil)
|
|
|
|
var _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil)
|
|
var _ bridge.TypingPortal = (*Portal)(nil)
|
|
var _ bridge.DisappearingPortal = (*Portal)(nil)
|
|
|
|
//var _ bridge.MembershipHandlingPortal = (*Portal)(nil)
|
|
//var _ bridge.MetaHandlingPortal = (*Portal)(nil)
|
|
|
|
// ** bridge.Portal Interface **
|
|
|
|
func (portal *Portal) IsEncrypted() bool {
|
|
return portal.Encrypted
|
|
}
|
|
|
|
func (portal *Portal) MarkEncrypted() {
|
|
portal.Encrypted = true
|
|
err := portal.Update(context.TODO())
|
|
if err != nil {
|
|
portal.log.Err(err).Msg("Failed to update portal in database after marking as encrypted")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) {
|
|
if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() {
|
|
portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) GetRelayUser() *User {
|
|
if !portal.HasRelaybot() {
|
|
return nil
|
|
} else if portal.relayUser == nil {
|
|
portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID)
|
|
}
|
|
return portal.relayUser
|
|
}
|
|
|
|
func isUUID(s string) bool {
|
|
if _, uuidErr := uuid.Parse(s); uuidErr == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (portal *Portal) IsPrivateChat() bool {
|
|
// If ChatID is a UUID, it's a private chat, otherwise it's base64 and a group chat
|
|
return isUUID(portal.ChatID)
|
|
}
|
|
|
|
func (portal *Portal) MainIntent() *appservice.IntentAPI {
|
|
if portal.IsPrivateChat() {
|
|
return portal.bridge.GetPuppetBySignalID(portal.UserID()).DefaultIntent()
|
|
}
|
|
|
|
return portal.bridge.Bot
|
|
}
|
|
|
|
type CustomBridgeInfoContent struct {
|
|
event.BridgeEventContent
|
|
RoomType string `json:"com.beeper.room_type,omitempty"`
|
|
}
|
|
|
|
func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) {
|
|
bridgeInfo := event.BridgeEventContent{
|
|
BridgeBot: portal.bridge.Bot.UserID,
|
|
Creator: portal.MainIntent().UserID,
|
|
Protocol: event.BridgeInfoSection{
|
|
ID: "signal",
|
|
DisplayName: "Signal",
|
|
AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
|
|
ExternalURL: "https://signal.org/",
|
|
},
|
|
Channel: event.BridgeInfoSection{
|
|
ID: portal.ChatID,
|
|
DisplayName: portal.Name,
|
|
AvatarURL: portal.AvatarURL.CUString(),
|
|
},
|
|
}
|
|
var bridgeInfoStateKey string
|
|
bridgeInfoStateKey = fmt.Sprintf("fi.mau.signal://signal/%s", portal.ChatID)
|
|
bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://signal.me/#p/%s", portal.ChatID)
|
|
var roomType string
|
|
if portal.IsPrivateChat() {
|
|
roomType = "dm"
|
|
}
|
|
return bridgeInfoStateKey, CustomBridgeInfoContent{bridgeInfo, roomType}
|
|
}
|
|
|
|
func (portal *Portal) UpdateBridgeInfo() {
|
|
if len(portal.MXID) == 0 {
|
|
portal.log.Debug().Msg("Not updating bridge info: no Matrix room created")
|
|
return
|
|
}
|
|
portal.log.Debug().Msg("Updating bridge info...")
|
|
stateKey, content := portal.getBridgeInfo()
|
|
_, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content)
|
|
if err != nil {
|
|
portal.log.Warn().Err(err).Msg("Failed to update m.bridge")
|
|
}
|
|
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
|
_, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content)
|
|
if err != nil {
|
|
portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge")
|
|
}
|
|
}
|
|
|
|
// ** bridge.ChildOverride methods (for SignalBridge in main.go) **
|
|
|
|
func (br *SignalBridge) GetAllPortalsWithMXID() []*Portal {
|
|
portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO()))
|
|
if err != nil {
|
|
br.ZLog.Err(err).Msg("Failed to get all portals with mxid")
|
|
return nil
|
|
}
|
|
return portals
|
|
}
|
|
|
|
func (br *SignalBridge) GetAllIPortals() (iportals []bridge.Portal) {
|
|
portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO()))
|
|
if err != nil {
|
|
br.ZLog.Err(err).Msg("Failed to get all portals with mxid")
|
|
return nil
|
|
}
|
|
iportals = make([]bridge.Portal, len(portals))
|
|
for i, portal := range portals {
|
|
iportals[i] = portal
|
|
}
|
|
return iportals
|
|
}
|
|
|
|
func (br *SignalBridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) ([]*Portal, error) {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
br.portalsLock.Lock()
|
|
defer br.portalsLock.Unlock()
|
|
|
|
output := make([]*Portal, len(dbPortals))
|
|
for index, dbPortal := range dbPortals {
|
|
if dbPortal == nil {
|
|
continue
|
|
}
|
|
|
|
portal, ok := br.portalsByID[dbPortal.PortalKey]
|
|
if !ok {
|
|
portal = br.loadPortal(context.TODO(), dbPortal, nil)
|
|
}
|
|
|
|
output[index] = portal
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// ** Portal Creation and Message Handling **
|
|
|
|
var signalFormatParams *signalfmt.FormatParams
|
|
var matrixFormatParams *matrixfmt.HTMLParser
|
|
|
|
func (br *SignalBridge) NewPortal(dbPortal *database.Portal) *Portal {
|
|
portal := &Portal{
|
|
Portal: dbPortal,
|
|
bridge: br,
|
|
log: br.ZLog.With().Str("chat_id", dbPortal.ChatID).Logger(),
|
|
|
|
signalMessages: make(chan portalSignalMessage, br.Config.Bridge.PortalMessageBuffer),
|
|
matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer),
|
|
}
|
|
portal.MsgConv = &msgconv.MessageConverter{
|
|
PortalMethods: portal,
|
|
SignalFmtParams: signalFormatParams,
|
|
MatrixFmtParams: matrixFormatParams,
|
|
ConvertVoiceMessages: true,
|
|
MaxFileSize: br.MediaConfig.UploadSize,
|
|
}
|
|
go portal.messageLoop()
|
|
|
|
return portal
|
|
}
|
|
|
|
func (portal *Portal) messageLoop() {
|
|
for {
|
|
select {
|
|
case msg := <-portal.matrixMessages:
|
|
portal.handleMatrixMessages(msg)
|
|
case msg := <-portal.signalMessages:
|
|
portal.handleSignalMessage(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) {
|
|
// If we have no SignalDevice, the bridge isn't logged in properly,
|
|
// so send BAD_CREDENTIALS so the user knows
|
|
if !msg.user.SignalDevice.IsDeviceLoggedIn() && !portal.HasRelaybot() {
|
|
go portal.sendMessageMetrics(msg.evt, errUserNotLoggedIn, "Ignoring", nil)
|
|
msg.user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"})
|
|
return
|
|
}
|
|
log := portal.log.With().
|
|
Str("action", "handle matrix event").
|
|
Str("event_id", msg.evt.ID.String()).
|
|
Str("event_type", msg.evt.Type.String()).
|
|
Logger()
|
|
ctx := log.WithContext(context.TODO())
|
|
|
|
switch msg.evt.Type {
|
|
case event.EventMessage, event.EventSticker:
|
|
portal.handleMatrixMessage(ctx, msg.user, msg.evt)
|
|
case event.EventRedaction:
|
|
portal.handleMatrixRedaction(ctx, msg.user, msg.evt)
|
|
case event.EventReaction:
|
|
portal.handleMatrixReaction(ctx, msg.user, msg.evt)
|
|
default:
|
|
log.Warn().Str("type", msg.evt.Type.Type).Msg("Unhandled matrix message type")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *User, evt *event.Event) {
|
|
log := zerolog.Ctx(ctx)
|
|
evtTS := time.UnixMilli(evt.Timestamp)
|
|
timings := messageTimings{
|
|
initReceive: evt.Mautrix.ReceivedAt.Sub(evtTS),
|
|
decrypt: evt.Mautrix.DecryptionDuration,
|
|
totalReceive: time.Since(evtTS),
|
|
}
|
|
implicitRRStart := time.Now()
|
|
portal.handleMatrixReadReceipt(sender, "", uint64(evt.Timestamp), false)
|
|
timings.implicitRR = time.Since(implicitRRStart)
|
|
start := time.Now()
|
|
|
|
messageAge := timings.totalReceive
|
|
ms := metricSender{portal: portal, timings: &timings}
|
|
log.Debug().
|
|
Str("sender", evt.Sender.String()).
|
|
Dur("age", messageAge).
|
|
Msg("Received message")
|
|
|
|
errorAfter := portal.bridge.Config.Bridge.MessageHandlingTimeout.ErrorAfter
|
|
deadline := portal.bridge.Config.Bridge.MessageHandlingTimeout.Deadline
|
|
isScheduled, _ := evt.Content.Raw["com.beeper.scheduled"].(bool)
|
|
if isScheduled {
|
|
log.Debug().Msg("Message is a scheduled message, extending handling timeouts")
|
|
errorAfter *= 10
|
|
deadline *= 10
|
|
}
|
|
|
|
if errorAfter > 0 {
|
|
remainingTime := errorAfter - messageAge
|
|
if remainingTime < 0 {
|
|
go ms.sendMessageMetrics(evt, errTimeoutBeforeHandling, "Timeout handling", true)
|
|
return
|
|
} else if remainingTime < 1*time.Second {
|
|
log.Warn().
|
|
Dur("remaining_time", remainingTime).
|
|
Dur("max_timeout", errorAfter).
|
|
Msg("Message was delayed before reaching the bridge")
|
|
}
|
|
go func() {
|
|
time.Sleep(remainingTime)
|
|
ms.sendMessageMetrics(evt, errMessageTakingLong, "Timeout handling", false)
|
|
}()
|
|
}
|
|
|
|
if deadline > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, deadline)
|
|
defer cancel()
|
|
}
|
|
|
|
timings.preproc = time.Since(start)
|
|
start = time.Now()
|
|
|
|
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
|
if !ok {
|
|
log.Error().Type("content_type", content).Msg("Unexpected parsed content type")
|
|
go ms.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Error converting", true)
|
|
return
|
|
}
|
|
|
|
realSenderMXID := sender.MXID
|
|
isRelay := false
|
|
if !sender.IsLoggedIn() {
|
|
if !portal.HasRelaybot() {
|
|
go ms.sendMessageMetrics(evt, errUserNotLoggedIn, "Error converting", true)
|
|
return
|
|
}
|
|
sender = portal.GetRelayUser()
|
|
if !sender.IsLoggedIn() {
|
|
go ms.sendMessageMetrics(evt, errRelaybotNotLoggedIn, "Error converting", true)
|
|
return
|
|
}
|
|
isRelay = true
|
|
}
|
|
|
|
var editTargetMsg *database.Message
|
|
if editTarget := content.RelatesTo.GetReplaceID(); editTarget != "" {
|
|
var err error
|
|
editTargetMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, editTarget)
|
|
if err != nil {
|
|
log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Failed to get edit target message")
|
|
go ms.sendMessageMetrics(evt, errFailedToGetEditTarget, "Error converting", true)
|
|
return
|
|
} else if editTargetMsg == nil {
|
|
log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Edit target message not found")
|
|
go ms.sendMessageMetrics(evt, errEditUnknownTarget, "Error converting", true)
|
|
return
|
|
} else if editTargetMsg.Sender != sender.SignalID {
|
|
go ms.sendMessageMetrics(evt, errEditDifferentSender, "Error converting", true)
|
|
return
|
|
}
|
|
if content.NewContent != nil {
|
|
content = content.NewContent
|
|
evt.Content.Parsed = content
|
|
}
|
|
}
|
|
|
|
relaybotFormatted := isRelay && portal.addRelaybotFormat(realSenderMXID, evt, content)
|
|
if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices {
|
|
go ms.sendMessageMetrics(evt, errMNoticeDisabled, "Error converting", true)
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, msgconvContextKeyClient, sender.SignalDevice)
|
|
msg, err := portal.MsgConv.ToSignal(ctx, evt, content, relaybotFormatted)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to convert message")
|
|
go ms.sendMessageMetrics(evt, err, "Error converting", true)
|
|
return
|
|
}
|
|
var wrappedMsg *signalpb.Content
|
|
if editTargetMsg == nil {
|
|
wrappedMsg = &signalpb.Content{
|
|
DataMessage: msg,
|
|
}
|
|
} else {
|
|
wrappedMsg = &signalpb.Content{
|
|
EditMessage: &signalpb.EditMessage{
|
|
TargetSentTimestamp: proto.Uint64(editTargetMsg.Timestamp),
|
|
DataMessage: msg,
|
|
},
|
|
}
|
|
}
|
|
|
|
timings.convert = time.Since(start)
|
|
start = time.Now()
|
|
|
|
err = portal.sendSignalMessage(ctx, wrappedMsg, sender, evt.ID)
|
|
|
|
timings.totalSend = time.Since(start)
|
|
go ms.sendMessageMetrics(evt, err, "Error sending", true)
|
|
if err == nil {
|
|
if editTargetMsg != nil {
|
|
err = editTargetMsg.SetTimestamp(ctx, msg.GetTimestamp())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to update message timestamp in database after editing")
|
|
}
|
|
} else {
|
|
portal.storeMessageInDB(ctx, evt.ID, sender.SignalID, msg.GetTimestamp(), 0)
|
|
if portal.ExpirationTime > 0 {
|
|
portal.addDisappearingMessage(ctx, evt.ID, uint32(portal.ExpirationTime), true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *User, evt *event.Event) {
|
|
log := zerolog.Ctx(ctx)
|
|
// Find the original signal message based on eventID
|
|
dbMessage, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get redaction target message")
|
|
}
|
|
// Might be a reaction redaction, find the original message for the reaction
|
|
dbReaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get redaction target reaction")
|
|
}
|
|
|
|
if !sender.IsLoggedIn() {
|
|
sender = portal.GetRelayUser()
|
|
}
|
|
|
|
if dbMessage != nil {
|
|
if dbMessage.Sender != sender.SignalID {
|
|
portal.sendMessageStatusCheckpointFailed(evt, errRedactionTargetSentBySomeoneElse)
|
|
return
|
|
}
|
|
msg := signalmeow.DataMessageForDelete(dbMessage.Timestamp)
|
|
err = portal.sendSignalMessage(ctx, msg, sender, evt.ID)
|
|
if err != nil {
|
|
portal.sendMessageStatusCheckpointFailed(evt, err)
|
|
log.Err(err).Msg("Failed to send message redaction to Signal")
|
|
return
|
|
}
|
|
err = dbMessage.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to delete redacted message from database")
|
|
} else if otherParts, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, dbMessage.Sender, dbMessage.Timestamp, portal.Receiver); err != nil {
|
|
log.Err(err).Msg("Failed to get other parts of redacted message from database")
|
|
} else if len(otherParts) > 0 {
|
|
// If there are other parts of the message, send a redaction for each of them
|
|
for _, otherPart := range otherParts {
|
|
_, err = portal.MainIntent().RedactEvent(portal.MXID, otherPart.MXID, mautrix.ReqRedact{
|
|
Reason: "Other part of Signal message redacted",
|
|
TxnID: "mxsg_partredact_" + otherPart.MXID.String(),
|
|
})
|
|
if err != nil {
|
|
log.Err(err).
|
|
Str("part_event_id", otherPart.MXID.String()).
|
|
Int("part_index", otherPart.PartIndex).
|
|
Msg("Failed to redact other part of redacted message")
|
|
}
|
|
err = otherPart.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).
|
|
Str("part_event_id", otherPart.MXID.String()).
|
|
Int("part_index", otherPart.PartIndex).
|
|
Msg("Failed to delete other part of redacted message from database")
|
|
}
|
|
}
|
|
}
|
|
portal.sendMessageStatusCheckpointSuccess(evt)
|
|
} else if dbReaction != nil {
|
|
if dbReaction.Author != sender.SignalID {
|
|
portal.sendMessageStatusCheckpointFailed(evt, errUnreactTargetSentBySomeoneElse)
|
|
return
|
|
}
|
|
msg := signalmeow.DataMessageForReaction(dbReaction.Emoji, dbReaction.MsgAuthor, dbReaction.MsgTimestamp, true)
|
|
err = portal.sendSignalMessage(ctx, msg, sender, evt.ID)
|
|
if err != nil {
|
|
portal.sendMessageStatusCheckpointFailed(evt, err)
|
|
log.Err(err).Msg("Failed to send reaction redaction to Signal")
|
|
return
|
|
}
|
|
err = dbReaction.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to delete redacted reaction from database")
|
|
}
|
|
portal.sendMessageStatusCheckpointSuccess(evt)
|
|
} else {
|
|
portal.sendMessageStatusCheckpointFailed(evt, errRedactionTargetNotFound)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) {
|
|
log := zerolog.Ctx(ctx)
|
|
if !sender.IsLoggedIn() {
|
|
log.Error().Msg("Cannot relay reaction from non-logged-in user. Ignoring")
|
|
return
|
|
}
|
|
// Find the original signal message based on eventID
|
|
relatedEventID := evt.Content.AsReaction().RelatesTo.EventID
|
|
targetMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, relatedEventID)
|
|
if err != nil {
|
|
portal.sendMessageStatusCheckpointFailed(evt, err)
|
|
log.Err(err).Msg("Failed to get reaction target message")
|
|
return
|
|
} else if targetMsg == nil {
|
|
portal.sendMessageStatusCheckpointFailed(evt, errReactionTargetNotFound)
|
|
log.Warn().Msg("Reaction target message not found")
|
|
return
|
|
}
|
|
emoji := evt.Content.AsReaction().RelatesTo.Key
|
|
signalEmoji := variationselector.FullyQualify(emoji) // Signal seems to require fully qualified emojis
|
|
msg := signalmeow.DataMessageForReaction(signalEmoji, targetMsg.Sender, targetMsg.Timestamp, false)
|
|
err = portal.sendSignalMessage(ctx, msg, sender, evt.ID)
|
|
if err != nil {
|
|
portal.sendMessageStatusCheckpointFailed(evt, err)
|
|
portal.log.Error().Msgf("Failed to send reaction %s", evt.ID)
|
|
return
|
|
}
|
|
|
|
// Signal only allows one reaction from each user
|
|
// Check if there's an existing reaction in the database for this sender and redact/delete it
|
|
dbReaction, err := portal.bridge.DB.Reaction.GetBySignalID(
|
|
ctx,
|
|
targetMsg.Sender,
|
|
targetMsg.Timestamp,
|
|
sender.SignalID,
|
|
portal.Receiver,
|
|
)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get existing reaction from database")
|
|
} else if dbReaction != nil {
|
|
log.Debug().Stringer("existing_event_id", dbReaction.MXID).Msg("Redacting existing reaction after sending new one")
|
|
_, err = portal.MainIntent().RedactEvent(portal.MXID, dbReaction.MXID)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to redact existing reaction")
|
|
}
|
|
}
|
|
if dbReaction != nil {
|
|
dbReaction.MXID = evt.ID
|
|
dbReaction.Emoji = signalEmoji
|
|
err = dbReaction.Update(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to update reaction in database")
|
|
}
|
|
} else {
|
|
dbReaction = portal.bridge.DB.Reaction.New()
|
|
dbReaction.MXID = evt.ID
|
|
dbReaction.RoomID = portal.MXID
|
|
dbReaction.SignalChatID = portal.ChatID
|
|
dbReaction.SignalReceiver = portal.Receiver
|
|
dbReaction.Author = sender.SignalID
|
|
dbReaction.MsgAuthor = targetMsg.Sender
|
|
dbReaction.MsgTimestamp = targetMsg.Timestamp
|
|
dbReaction.Emoji = signalEmoji
|
|
err = dbReaction.Insert(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to insert reaction to database")
|
|
}
|
|
}
|
|
|
|
portal.sendMessageStatusCheckpointSuccess(evt)
|
|
}
|
|
|
|
func (portal *Portal) sendSignalMessage(ctx context.Context, msg *signalpb.Content, sender *User, evtID id.EventID) error {
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("action", "send_signal_message").
|
|
Str("event_id", evtID.String()).
|
|
Str("portal_chat_id", portal.ChatID).
|
|
Logger()
|
|
ctx = log.WithContext(ctx)
|
|
|
|
log.Debug().Msg("Sending event to Signal")
|
|
|
|
// Check to see if portal.ChatID is a standard UUID (with dashes)
|
|
var err error
|
|
if portal.IsPrivateChat() {
|
|
// this is a 1:1 chat
|
|
result := signalmeow.SendMessage(ctx, sender.SignalDevice, portal.UserID(), msg)
|
|
if !result.WasSuccessful {
|
|
err = result.FailedSendResult.Error
|
|
log.Err(err).Msg("Error sending event to Signal")
|
|
}
|
|
} else {
|
|
// this is a group chat
|
|
groupID := types.GroupIdentifier(portal.ChatID)
|
|
result, err := signalmeow.SendGroupMessage(ctx, sender.SignalDevice, groupID, msg)
|
|
if err != nil {
|
|
// check the start of the error string, see if it starts with "No group master key found for group identifier"
|
|
if strings.HasPrefix(err.Error(), "No group master key found for group identifier") {
|
|
portal.MainIntent().SendNotice(portal.MXID, "Missing group encryption key. Please ask a group member to send a message in this chat, then retry sending.")
|
|
}
|
|
log.Err(err).Msg("Error sending event to Signal group")
|
|
return err
|
|
}
|
|
totalRecipients := len(result.FailedToSendTo) + len(result.SuccessfullySentTo)
|
|
log = log.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.FailedToSendTo) > 0 {
|
|
log.Error().Msg("Failed to send event to some members of Signal group")
|
|
}
|
|
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")
|
|
err = errors.New("failed to send to any members of Signal group")
|
|
} else if len(result.SuccessfullySentTo) < totalRecipients {
|
|
log.Warn().Msg("Only sent event to some members of Signal group")
|
|
} else {
|
|
log.Debug().Msgf("Sent event to all members of Signal group")
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (portal *Portal) sendMessageStatusCheckpointSuccess(evt *event.Event) {
|
|
portal.sendDeliveryReceipt(evt.ID)
|
|
portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0)
|
|
|
|
var deliveredTo *[]id.UserID
|
|
if portal.IsPrivateChat() {
|
|
deliveredTo = &[]id.UserID{}
|
|
}
|
|
portal.sendStatusEvent(evt.ID, "", nil, deliveredTo)
|
|
}
|
|
|
|
func (portal *Portal) sendMessageStatusCheckpointFailed(evt *event.Event, err error) {
|
|
portal.sendDeliveryReceipt(evt.ID)
|
|
portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0)
|
|
portal.sendStatusEvent(evt.ID, "", err, nil)
|
|
}
|
|
|
|
type msgconvContextKey int
|
|
|
|
const (
|
|
msgconvContextKeyIntent msgconvContextKey = iota
|
|
msgconvContextKeyClient
|
|
)
|
|
|
|
func (portal *Portal) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) {
|
|
intent := ctx.Value(msgconvContextKeyIntent).(*appservice.IntentAPI)
|
|
req := mautrix.ReqUploadMedia{
|
|
ContentBytes: data,
|
|
ContentType: contentType,
|
|
FileName: fileName,
|
|
}
|
|
if portal.bridge.Config.Homeserver.AsyncMedia {
|
|
uploaded, err := intent.UploadAsync(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return uploaded.ContentURI.CUString(), nil
|
|
} else {
|
|
uploaded, err := intent.UploadMedia(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return uploaded.ContentURI.CUString(), nil
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) DownloadMatrixMedia(ctx context.Context, uriString id.ContentURIString) ([]byte, error) {
|
|
parsedURI, err := uriString.Parse()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed content URI: %w", err)
|
|
}
|
|
return portal.MainIntent().DownloadBytesContext(ctx, parsedURI)
|
|
}
|
|
|
|
func (portal *Portal) GetData(ctx context.Context) *database.Portal {
|
|
return portal.Portal
|
|
}
|
|
|
|
func (portal *Portal) GetClient(ctx context.Context) *signalmeow.Device {
|
|
return ctx.Value(msgconvContextKeyClient).(*signalmeow.Device)
|
|
}
|
|
|
|
func (portal *Portal) GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID) {
|
|
if msg == nil {
|
|
return
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("reply_target_author", msg.GetAuthorAci()).
|
|
Uint64("reply_target_ts", msg.GetId()).
|
|
Logger()
|
|
if senderUUID, err := uuid.Parse(msg.GetAuthorAci()); err != nil {
|
|
log.Err(err).Msg("Failed to parse sender UUID in Signal quote")
|
|
} else if message, err := portal.bridge.DB.Message.GetBySignalID(ctx, senderUUID, msg.GetId(), 0, portal.Receiver); err != nil {
|
|
log.Err(err).Msg("Failed to get reply target message from database")
|
|
} else if message == nil {
|
|
log.Warn().Msg("Reply target message not found")
|
|
} else {
|
|
replyTo = message.MXID
|
|
targetUser := portal.bridge.GetUserBySignalID(message.Sender)
|
|
if targetUser != nil {
|
|
replyTargetSender = targetUser.MXID
|
|
} else {
|
|
replyTargetSender = portal.bridge.FormatPuppetMXID(message.Sender)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (portal *Portal) GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote {
|
|
replyToID := content.RelatesTo.GetReplyTo()
|
|
if len(replyToID) == 0 {
|
|
return nil
|
|
}
|
|
replyToMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, replyToID)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).
|
|
Str("reply_to_mxid", replyToID.String()).
|
|
Msg("Failed to get reply target message from database")
|
|
} else if replyToMsg == nil {
|
|
zerolog.Ctx(ctx).Warn().
|
|
Str("reply_to_mxid", replyToID.String()).
|
|
Msg("Reply target message not found")
|
|
} else {
|
|
return &signalpb.DataMessage_Quote{
|
|
Id: proto.Uint64(replyToMsg.Timestamp),
|
|
AuthorAci: proto.String(replyToMsg.Sender.String()),
|
|
Type: signalpb.DataMessage_Quote_NORMAL.Enum(),
|
|
|
|
// This is a hack to make Signal iOS and desktop render replies to file messages.
|
|
// Unfortunately it also makes Signal Desktop show a file icon on replies to text messages.
|
|
// TODO store file or text flag in database and fill this field only when replying to file messages.
|
|
Attachments: make([]*signalpb.DataMessage_Quote_QuotedAttachment, 0),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (portal *Portal) handleSignalMessage(portalMessage portalSignalMessage) {
|
|
sender := portal.bridge.GetPuppetBySignalID(portalMessage.evt.Info.Sender)
|
|
if sender == nil {
|
|
portal.log.Warn().
|
|
Str("sender_uuid", portalMessage.evt.Info.Sender.String()).
|
|
Msg("Couldn't get puppet for message")
|
|
return
|
|
}
|
|
switch typedEvt := portalMessage.evt.Event.(type) {
|
|
case *signalpb.DataMessage:
|
|
portal.handleSignalDataMessage(portalMessage.user, sender, typedEvt)
|
|
case *signalpb.TypingMessage:
|
|
portal.handleSignalTypingMessage(sender, typedEvt)
|
|
case *signalpb.EditMessage:
|
|
portal.handleSignalEditMessage(sender, typedEvt.GetTargetSentTimestamp(), typedEvt.GetDataMessage())
|
|
default:
|
|
portal.log.Error().
|
|
Type("data_type", typedEvt).
|
|
Msg("Invalid inner event type inside ChatEvent")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleSignalDataMessage(source *User, sender *Puppet, msg *signalpb.DataMessage) {
|
|
// FIXME hacky
|
|
updatePuppetWithSignalContact(context.TODO(), source, sender, nil)
|
|
|
|
switch {
|
|
case msgconv.CanConvertSignal(msg):
|
|
portal.handleSignalNormalDataMessage(source, sender, msg)
|
|
case msg.Reaction != nil:
|
|
portal.handleSignalReaction(sender, msg.Reaction, msg.GetTimestamp())
|
|
case msg.Delete != nil:
|
|
portal.handleSignalDelete(sender, msg.Delete, msg.GetTimestamp())
|
|
case msg.StoryContext != nil, msg.GroupCallUpdate != nil:
|
|
// ignore
|
|
default:
|
|
portal.log.Warn().
|
|
Str("action", "handle signal message").
|
|
Str("sender_uuid", sender.SignalID.String()).
|
|
Uint64("msg_ts", msg.GetTimestamp()).
|
|
Msg("Unrecognized content in message")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleSignalReaction(sender *Puppet, react *signalpb.DataMessage_Reaction, ts uint64) {
|
|
log := portal.log.With().
|
|
Str("action", "handle signal reaction").
|
|
Str("sender_uuid", sender.SignalID.String()).
|
|
Uint64("target_msg_ts", react.GetTargetSentTimestamp()).
|
|
Str("target_msg_sender", react.GetTargetAuthorAci()).
|
|
Bool("remove", react.GetRemove()).
|
|
Logger()
|
|
ctx := log.WithContext(context.TODO())
|
|
targetSenderUUID, err := uuid.Parse(react.GetTargetAuthorAci())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to parse target message sender UUID")
|
|
return
|
|
}
|
|
targetMsg, err := portal.bridge.DB.Message.GetBySignalID(ctx, targetSenderUUID, react.GetTargetSentTimestamp(), 0, portal.Receiver)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get target message from database")
|
|
return
|
|
} else if targetMsg == nil {
|
|
log.Warn().Msg("Target message not found")
|
|
return
|
|
}
|
|
existingReaction, err := portal.bridge.DB.Reaction.GetBySignalID(ctx, targetMsg.Sender, targetMsg.Timestamp, sender.SignalID, portal.Receiver)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get existing reaction from database")
|
|
return
|
|
}
|
|
intent := sender.IntentFor(portal)
|
|
if existingReaction != nil {
|
|
_, err = intent.RedactEvent(portal.MXID, existingReaction.MXID, mautrix.ReqRedact{
|
|
TxnID: "mxsg_unreact_" + existingReaction.MXID.String(),
|
|
})
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to redact reaction")
|
|
}
|
|
if react.GetRemove() {
|
|
err = existingReaction.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to remove reaction from database after redacting")
|
|
}
|
|
return
|
|
}
|
|
} else if react.GetRemove() {
|
|
log.Warn().Msg("Existing reaction for removal not found")
|
|
return
|
|
}
|
|
// Create a new message event with the reaction
|
|
content := &event.ReactionEventContent{
|
|
RelatesTo: event.RelatesTo{
|
|
Type: event.RelAnnotation,
|
|
Key: variationselector.Add(react.GetEmoji()),
|
|
EventID: targetMsg.MXID,
|
|
},
|
|
}
|
|
resp, err := portal.sendMatrixEvent(intent, event.EventReaction, content, nil, int64(ts))
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to send reaction")
|
|
return
|
|
}
|
|
if existingReaction == nil {
|
|
dbReaction := portal.bridge.DB.Reaction.New()
|
|
dbReaction.MXID = resp.EventID
|
|
dbReaction.RoomID = portal.MXID
|
|
dbReaction.SignalChatID = portal.ChatID
|
|
dbReaction.SignalReceiver = portal.Receiver
|
|
dbReaction.Author = sender.SignalID
|
|
dbReaction.MsgAuthor = targetMsg.Sender
|
|
dbReaction.MsgTimestamp = targetMsg.Timestamp
|
|
dbReaction.Emoji = react.GetEmoji()
|
|
err = dbReaction.Insert(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to insert reaction to database")
|
|
}
|
|
} else {
|
|
existingReaction.Emoji = react.GetEmoji()
|
|
existingReaction.MXID = resp.EventID
|
|
err = existingReaction.Update(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to update reaction in database")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleSignalDelete(sender *Puppet, delete *signalpb.DataMessage_Delete, ts uint64) {
|
|
log := portal.log.With().
|
|
Str("action", "handle signal delete").
|
|
Str("sender_uuid", sender.SignalID.String()).
|
|
Uint64("target_msg_ts", delete.GetTargetSentTimestamp()).
|
|
Uint64("delete_ts", ts).
|
|
Logger()
|
|
ctx := log.WithContext(context.TODO())
|
|
targetMsg, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, sender.SignalID, delete.GetTargetSentTimestamp(), portal.Receiver)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get target message from database")
|
|
return
|
|
} else if len(targetMsg) == 0 {
|
|
log.Warn().Msg("Target message not found")
|
|
return
|
|
}
|
|
intent := sender.IntentFor(portal)
|
|
for _, part := range targetMsg {
|
|
_, err = intent.RedactEvent(portal.MXID, part.MXID, mautrix.ReqRedact{
|
|
TxnID: "mxsg_delete_" + part.MXID.String(),
|
|
})
|
|
if err != nil {
|
|
log.Err(err).
|
|
Int("part_index", part.PartIndex).
|
|
Str("event_id", part.MXID.String()).
|
|
Msg("Failed to redact message")
|
|
}
|
|
err = part.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).
|
|
Int("part_index", part.PartIndex).
|
|
Msg("Failed to delete message from database")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleSignalNormalDataMessage(source *User, sender *Puppet, msg *signalpb.DataMessage) {
|
|
log := portal.log.With().
|
|
Str("action", "handle signal message").
|
|
Str("sender_uuid", sender.SignalID.String()).
|
|
Uint64("msg_ts", msg.GetTimestamp()).
|
|
Logger()
|
|
ctx := log.WithContext(context.TODO())
|
|
if portal.MXID == "" {
|
|
log.Debug().Msg("Creating Matrix room from incoming message")
|
|
if err := portal.CreateMatrixRoom(source, nil); err != nil {
|
|
log.Error().Err(err).Msg("Failed to create portal room")
|
|
return
|
|
}
|
|
// FIXME hacky
|
|
ensureGroupPuppetsAreJoinedToPortal(context.Background(), source, portal)
|
|
signalmeow.SendContactSyncRequest(context.TODO(), source.SignalDevice)
|
|
}
|
|
|
|
existingMessage, err := portal.bridge.DB.Message.GetBySignalID(ctx, sender.SignalID, msg.GetTimestamp(), 0, portal.Receiver)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to check if message was already bridged")
|
|
return
|
|
} else if existingMessage != nil {
|
|
log.Debug().Msg("Ignoring duplicate message")
|
|
return
|
|
}
|
|
|
|
intent := sender.IntentFor(portal)
|
|
ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent)
|
|
converted := portal.MsgConv.ToMatrix(ctx, msg)
|
|
if portal.bridge.Config.Bridge.CaptionInMessage {
|
|
converted.MergeCaption()
|
|
}
|
|
for i, part := range converted.Parts {
|
|
resp, err := portal.sendMatrixEvent(intent, part.Type, part.Content, part.Extra, int64(converted.Timestamp))
|
|
if err != nil {
|
|
log.Err(err).Int("part_index", i).Msg("Failed to send message to Matrix")
|
|
continue
|
|
}
|
|
portal.storeMessageInDB(ctx, resp.EventID, sender.SignalID, converted.Timestamp, i)
|
|
if converted.DisappearIn != 0 {
|
|
portal.addDisappearingMessage(ctx, resp.EventID, converted.DisappearIn, sender.SignalID == source.SignalID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleSignalEditMessage(sender *Puppet, timestamp uint64, msg *signalpb.DataMessage) {
|
|
log := portal.log.With().
|
|
Str("action", "handle signal edit").
|
|
Str("sender_uuid", sender.SignalID.String()).
|
|
Uint64("target_msg_ts", timestamp).
|
|
Uint64("edit_msg_ts", msg.GetTimestamp()).
|
|
Logger()
|
|
if portal.MXID == "" {
|
|
log.Debug().Msg("Dropping edit message in chat with no portal")
|
|
return
|
|
}
|
|
ctx := log.WithContext(context.TODO())
|
|
targetMessage, err := portal.bridge.DB.Message.GetAllPartsBySignalID(ctx, sender.SignalID, timestamp, portal.Receiver)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get target message")
|
|
return
|
|
} else if len(targetMessage) == 0 {
|
|
log.Debug().Msg("Target message not found (edit may have been already handled)")
|
|
return
|
|
}
|
|
|
|
intent := sender.IntentFor(portal)
|
|
ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent)
|
|
converted := portal.MsgConv.ToMatrix(ctx, msg)
|
|
if portal.bridge.Config.Bridge.CaptionInMessage {
|
|
converted.MergeCaption()
|
|
}
|
|
if len(converted.Parts) != len(targetMessage) {
|
|
log.Error().
|
|
Int("target_parts", len(targetMessage)).
|
|
Int("new_parts", len(converted.Parts)).
|
|
Msg("Mismatched number of parts in edit")
|
|
return
|
|
}
|
|
for i, part := range converted.Parts {
|
|
part.Content.SetEdit(targetMessage[i].MXID)
|
|
if part.Extra != nil {
|
|
part.Extra = map[string]any{
|
|
"m.new_content": part.Extra,
|
|
}
|
|
}
|
|
_, err = portal.sendMatrixEvent(intent, part.Type, part.Content, part.Extra, int64(converted.Timestamp))
|
|
if err != nil {
|
|
log.Err(err).Int("part_index", i).Msg("Failed to send edit to Matrix")
|
|
}
|
|
}
|
|
err = targetMessage[0].SetTimestamp(ctx, msg.GetTimestamp())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to update message edit timestamp in database")
|
|
}
|
|
}
|
|
|
|
const SignalTypingTimeout = 15 * time.Second
|
|
|
|
func (portal *Portal) handleSignalTypingMessage(sender *Puppet, msg *signalpb.TypingMessage) {
|
|
if portal.MXID == "" {
|
|
portal.log.Debug().Msg("Dropping typing message in chat with no portal")
|
|
return
|
|
}
|
|
intent := sender.IntentFor(portal)
|
|
// Don't bridge double puppeted typing notifications to avoid echoing
|
|
if intent.IsCustomPuppet {
|
|
return
|
|
}
|
|
var err error
|
|
switch msg.GetAction() {
|
|
case signalpb.TypingMessage_STARTED:
|
|
_, err = intent.UserTyping(portal.MXID, true, SignalTypingTimeout)
|
|
case signalpb.TypingMessage_STOPPED:
|
|
_, err = intent.UserTyping(portal.MXID, false, 0)
|
|
}
|
|
if err != nil {
|
|
portal.log.Err(err).
|
|
Str("user_id", sender.SignalID.String()).
|
|
Msg("Failed to handle Signal typing notification")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) storeMessageInDB(ctx context.Context, eventID id.EventID, senderSignalID uuid.UUID, timestamp uint64, partIndex int) {
|
|
dbMessage := portal.bridge.DB.Message.New()
|
|
dbMessage.MXID = eventID
|
|
dbMessage.RoomID = portal.MXID
|
|
dbMessage.Sender = senderSignalID
|
|
dbMessage.Timestamp = timestamp
|
|
dbMessage.PartIndex = partIndex
|
|
dbMessage.SignalChatID = portal.ChatID
|
|
dbMessage.SignalReceiver = portal.Receiver
|
|
err := dbMessage.Insert(ctx)
|
|
if err != nil {
|
|
portal.log.Err(err).Msg("Failed to insert message into database")
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) addDisappearingMessage(ctx context.Context, eventID id.EventID, expireInSeconds uint32, startTimerNow bool) {
|
|
portal.bridge.disappearingMessagesManager.AddDisappearingMessage(ctx, eventID, portal.MXID, time.Duration(expireInSeconds)*time.Second, startTimerNow)
|
|
}
|
|
|
|
func (portal *Portal) MarkDelivered(msg *database.Message) {
|
|
if !portal.IsPrivateChat() {
|
|
return
|
|
}
|
|
portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{
|
|
EventID: msg.MXID,
|
|
RoomID: portal.MXID,
|
|
Step: status.MsgStepRemote,
|
|
Timestamp: jsontime.UnixMilliNow(),
|
|
Status: status.MsgStatusDelivered,
|
|
ReportedBy: status.MsgReportedByBridge,
|
|
})
|
|
portal.sendStatusEvent(msg.MXID, "", nil, &[]id.UserID{portal.MainIntent().UserID})
|
|
}
|
|
|
|
type customReadReceipt struct {
|
|
Timestamp int64 `json:"ts,omitempty"`
|
|
DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"`
|
|
}
|
|
|
|
type customReadMarkers struct {
|
|
mautrix.ReqSetReadMarkers
|
|
ReadExtra customReadReceipt `json:"com.beeper.read.extra"`
|
|
FullyReadExtra customReadReceipt `json:"com.beeper.fully_read.extra"`
|
|
}
|
|
|
|
func (portal *Portal) SendReadReceipt(sender *Puppet, msg *database.Message) error {
|
|
intent := sender.IntentFor(portal)
|
|
if intent.IsCustomPuppet {
|
|
extra := customReadReceipt{DoublePuppetSource: portal.bridge.Name}
|
|
return intent.SetReadMarkers(portal.MXID, &customReadMarkers{
|
|
ReqSetReadMarkers: mautrix.ReqSetReadMarkers{
|
|
Read: msg.MXID,
|
|
FullyRead: msg.MXID,
|
|
},
|
|
ReadExtra: extra,
|
|
FullyReadExtra: extra,
|
|
})
|
|
} else {
|
|
return intent.MarkRead(portal.MXID, msg.MXID)
|
|
}
|
|
}
|
|
|
|
func typingDiff(prev, new []id.UserID) (started, stopped []id.UserID) {
|
|
OuterNew:
|
|
for _, userID := range new {
|
|
for _, previousUserID := range prev {
|
|
if userID == previousUserID {
|
|
continue OuterNew
|
|
}
|
|
}
|
|
started = append(started, userID)
|
|
}
|
|
OuterPrev:
|
|
for _, userID := range prev {
|
|
for _, previousUserID := range new {
|
|
if userID == previousUserID {
|
|
continue OuterPrev
|
|
}
|
|
}
|
|
stopped = append(stopped, userID)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (portal *Portal) setTyping(userIDs []id.UserID, isTyping bool) {
|
|
for _, userID := range userIDs {
|
|
user := portal.bridge.GetUserByMXID(userID)
|
|
if user == nil || !user.IsLoggedIn() {
|
|
continue
|
|
}
|
|
|
|
// Check to see if portal.ChatID is a standard UUID (with dashes)
|
|
// Note: not handling sending to a group right now, since that will
|
|
// require SenderKey sending to not be terrible
|
|
if portal.IsPrivateChat() {
|
|
// this is a 1:1 chat
|
|
portal.log.Debug().Msgf("Sending Typing event to Signal %s", portal.ChatID)
|
|
ctx := context.Background()
|
|
typingMessage := signalmeow.TypingMessage(isTyping)
|
|
result := signalmeow.SendMessage(ctx, user.SignalDevice, portal.UserID(), typingMessage)
|
|
if !result.WasSuccessful {
|
|
portal.log.Err(result.FailedSendResult.Error).Msg("Error sending event to Signal")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// mautrix-go TypingPortal interface
|
|
func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) {
|
|
portal.currentlyTypingLock.Lock()
|
|
defer portal.currentlyTypingLock.Unlock()
|
|
startedTyping, stoppedTyping := typingDiff(portal.currentlyTyping, newTyping)
|
|
portal.currentlyTyping = newTyping
|
|
portal.setTyping(startedTyping, true)
|
|
portal.setTyping(stoppedTyping, false)
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixReadReceipt(brSender bridge.User, eventID id.EventID, receipt event.ReadReceipt) {
|
|
portal.handleMatrixReadReceipt(brSender.(*User), eventID, uint64(receipt.Timestamp.UnixMilli()), true)
|
|
}
|
|
|
|
func (portal *Portal) handleMatrixReadReceipt(sender *User, eventID id.EventID, maxTimestamp uint64, isExplicit bool) {
|
|
logWith := portal.log.With().
|
|
Str("event_id", eventID.String()).
|
|
Str("sender", sender.MXID.String()).
|
|
Bool("explicit", isExplicit)
|
|
if isExplicit {
|
|
logWith = logWith.Str("action", "handle matrix read receipt")
|
|
}
|
|
log := logWith.Logger()
|
|
log.Debug().Msg("Handling Matrix read receipt")
|
|
portal.ScheduleDisappearing()
|
|
ctx := log.WithContext(context.TODO())
|
|
|
|
if isExplicit {
|
|
dbMessage, _ := portal.bridge.DB.Message.GetByMXID(ctx, eventID)
|
|
if dbMessage != nil {
|
|
maxTimestamp = dbMessage.Timestamp
|
|
}
|
|
}
|
|
prevLastReadTS := sender.GetLastReadTS(ctx, portal.PortalKey)
|
|
if maxTimestamp <= prevLastReadTS {
|
|
log.Debug().
|
|
Uint64("prev_last_read_ts", prevLastReadTS).
|
|
Uint64("max_timestamp", maxTimestamp).
|
|
Msg("Ignoring read receipt older than last read timestamp")
|
|
return
|
|
}
|
|
minTimestamp := prevLastReadTS
|
|
if minTimestamp == 0 {
|
|
minTimestamp = maxTimestamp - 2000
|
|
}
|
|
dbMessages, err := portal.bridge.DB.Message.GetAllBetweenTimestamps(ctx, portal.PortalKey, minTimestamp, maxTimestamp)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get messages between timestamps to mark as read")
|
|
return
|
|
}
|
|
messagesToRead := map[uuid.UUID][]uint64{}
|
|
for _, msg := range dbMessages {
|
|
messagesToRead[msg.Sender] = append(messagesToRead[msg.Sender], msg.Timestamp)
|
|
}
|
|
// Always update last read ts for non-explicit read receipts, because that means there's a message about to be sent
|
|
if (len(dbMessages) > 0 || !isExplicit) && maxTimestamp != prevLastReadTS {
|
|
sender.SetLastReadTS(ctx, portal.PortalKey, maxTimestamp)
|
|
}
|
|
if isExplicit || len(messagesToRead) > 0 {
|
|
log.Debug().
|
|
Any("targets", messagesToRead).
|
|
Uint64("prev_last_read_ts", prevLastReadTS).
|
|
Uint64("min_timestamp", minTimestamp).
|
|
Uint64("max_timestamp", maxTimestamp).
|
|
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 == sender.SignalID {
|
|
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(context.Background(), 10*time.Second)
|
|
result := signalmeow.SendMessage(ctx, sender.SignalDevice, destination, signalmeow.ReadReceptMessageForTimestamps(messages))
|
|
cancel()
|
|
if !result.WasSuccessful {
|
|
log.Err(result.FailedSendResult.Error).
|
|
Stringer("destination", destination).
|
|
Uints64("message_ids", messages).
|
|
Msg("Failed to send read receipt to Signal")
|
|
} else {
|
|
log.Debug().
|
|
Stringer("destination", destination).
|
|
Uints64("message_ids", messages).
|
|
Msg("Sent read receipt to Signal")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) sendMainIntentMessage(content *event.MessageEventContent) (*mautrix.RespSendEvent, error) {
|
|
return portal.sendMatrixEvent(portal.MainIntent(), event.EventMessage, content, nil, 0)
|
|
}
|
|
|
|
func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) {
|
|
if !portal.Encrypted || portal.bridge.Crypto == nil {
|
|
return eventType, nil
|
|
}
|
|
intent.AddDoublePuppetValue(content)
|
|
// TODO maybe the locking should be inside mautrix-go?
|
|
portal.encryptLock.Lock()
|
|
defer portal.encryptLock.Unlock()
|
|
err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content)
|
|
if err != nil {
|
|
return eventType, fmt.Errorf("failed to encrypt event: %w", err)
|
|
}
|
|
return event.EventEncrypted, nil
|
|
}
|
|
|
|
func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) {
|
|
if !portal.Encrypted {
|
|
return mimeType, nil
|
|
}
|
|
|
|
file := &event.EncryptedFileInfo{
|
|
EncryptedFile: *attachment.NewEncryptedFile(),
|
|
URL: "",
|
|
}
|
|
file.EncryptInPlace(data)
|
|
return "application/octet-stream", file
|
|
}
|
|
|
|
func (portal *Portal) uploadMediaToMatrix(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
|
|
uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType)
|
|
|
|
req := mautrix.ReqUploadMedia{
|
|
ContentBytes: data,
|
|
ContentType: uploadMimeType,
|
|
}
|
|
var mxc id.ContentURI
|
|
if portal.bridge.Config.Homeserver.AsyncMedia {
|
|
uploaded, err := intent.UploadAsync(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mxc = uploaded.ContentURI
|
|
} else {
|
|
uploaded, err := intent.UploadMedia(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mxc = uploaded.ContentURI
|
|
}
|
|
|
|
if file != nil {
|
|
file.URL = mxc.CUString()
|
|
content.File = file
|
|
} else {
|
|
content.URL = mxc.CUString()
|
|
}
|
|
|
|
content.Info.Size = len(data)
|
|
if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") {
|
|
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
|
content.Info.Width, content.Info.Height = cfg.Width, cfg.Height
|
|
}
|
|
|
|
// This is a hack for bad clients like Element iOS that require a thumbnail (https://github.com/vector-im/element-ios/issues/4004)
|
|
if strings.HasPrefix(content.Info.MimeType, "image/") && content.Info.ThumbnailInfo == nil {
|
|
infoCopy := *content.Info
|
|
content.Info.ThumbnailInfo = &infoCopy
|
|
if content.File != nil {
|
|
content.Info.ThumbnailFile = file
|
|
} else {
|
|
content.Info.ThumbnailURL = content.URL
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (portal *Portal) sendMatrixEvent(intent *appservice.IntentAPI, eventType event.Type, content any, extraContent map[string]any, timestamp int64) (*mautrix.RespSendEvent, error) {
|
|
wrappedContent := event.Content{Parsed: content, Raw: extraContent}
|
|
if eventType != event.EventReaction {
|
|
var err error
|
|
eventType, err = portal.encrypt(intent, &wrappedContent, eventType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
_, _ = intent.UserTyping(portal.MXID, false, 0)
|
|
return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
|
|
}
|
|
|
|
func (portal *Portal) getEncryptionEventContent() (evt *event.EncryptionEventContent) {
|
|
evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
|
|
if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom {
|
|
evt.RotationPeriodMillis = rot.Milliseconds
|
|
evt.RotationPeriodMessages = rot.Messages
|
|
}
|
|
return
|
|
}
|
|
|
|
func (portal *Portal) shouldSetDMRoomMetadata() bool {
|
|
return !portal.IsPrivateChat() ||
|
|
portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" ||
|
|
(portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never")
|
|
}
|
|
|
|
func (portal *Portal) ensureUserInvited(user *User) bool {
|
|
return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat())
|
|
}
|
|
|
|
func (portal *Portal) CreateMatrixRoom(user *User, meta *any) error {
|
|
portal.roomCreateLock.Lock()
|
|
defer portal.roomCreateLock.Unlock()
|
|
if portal.MXID != "" {
|
|
portal.log.Debug().Msg("Not creating room: already exists")
|
|
return nil
|
|
}
|
|
portal.log.Debug().Msg("Creating matrix room")
|
|
|
|
intent := portal.MainIntent()
|
|
|
|
if err := intent.EnsureRegistered(); err != nil {
|
|
portal.log.Error().Err(err).Msg("failed to ensure registered")
|
|
return err
|
|
}
|
|
|
|
bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo()
|
|
initialState := []*event.Event{{
|
|
Type: event.StateBridge,
|
|
Content: event.Content{Parsed: bridgeInfo},
|
|
StateKey: &bridgeInfoStateKey,
|
|
}, {
|
|
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
|
Type: event.StateHalfShotBridge,
|
|
Content: event.Content{Parsed: bridgeInfo},
|
|
StateKey: &bridgeInfoStateKey,
|
|
}}
|
|
|
|
if !portal.AvatarURL.IsEmpty() {
|
|
initialState = append(initialState, &event.Event{
|
|
Type: event.StateRoomAvatar,
|
|
Content: event.Content{Parsed: &event.RoomAvatarEventContent{
|
|
URL: portal.AvatarURL,
|
|
}},
|
|
})
|
|
}
|
|
|
|
creationContent := make(map[string]interface{})
|
|
if !portal.bridge.Config.Bridge.FederateRooms {
|
|
creationContent["m.federate"] = false
|
|
}
|
|
|
|
var invite []id.UserID
|
|
|
|
if portal.bridge.Config.Bridge.Encryption.Default {
|
|
initialState = append(initialState, &event.Event{
|
|
Type: event.StateEncryption,
|
|
Content: event.Content{
|
|
Parsed: portal.getEncryptionEventContent(),
|
|
},
|
|
})
|
|
portal.Encrypted = true
|
|
|
|
if portal.IsPrivateChat() {
|
|
invite = append(invite, portal.bridge.Bot.UserID)
|
|
}
|
|
}
|
|
|
|
// FIXME slightly hacky
|
|
user.syncPortalInfo(portal)
|
|
|
|
resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
|
|
Visibility: "private",
|
|
Name: portal.Name,
|
|
Topic: portal.Topic,
|
|
Invite: invite,
|
|
Preset: "private_chat",
|
|
IsDirect: portal.IsPrivateChat(),
|
|
InitialState: initialState,
|
|
CreationContent: creationContent,
|
|
})
|
|
if err != nil {
|
|
portal.log.Warn().Err(err).Msg("failed to create room")
|
|
return err
|
|
}
|
|
|
|
portal.NameSet = true
|
|
//portal.TopicSet = true
|
|
portal.AvatarSet = !portal.AvatarURL.IsEmpty()
|
|
portal.MXID = resp.RoomID
|
|
portal.bridge.portalsLock.Lock()
|
|
portal.bridge.portalsByMXID[portal.MXID] = portal
|
|
portal.bridge.portalsLock.Unlock()
|
|
err = portal.Update(context.TODO())
|
|
if err != nil {
|
|
portal.log.Err(err).Msg("Failed to save created portal mxid")
|
|
}
|
|
portal.log.Info().Msgf("Created matrix room %s", portal.MXID)
|
|
|
|
if portal.Encrypted && portal.IsPrivateChat() {
|
|
err = portal.bridge.Bot.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client})
|
|
if err != nil {
|
|
portal.log.Error().Err(err).Msg("Failed to ensure bridge bot is joined to private chat portal")
|
|
}
|
|
}
|
|
|
|
user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat())
|
|
user.syncChatDoublePuppetDetails(portal, true)
|
|
go portal.addToPersonalSpace(portal.log.WithContext(context.TODO()), user)
|
|
|
|
//portal.syncParticipants(user, channel.Recipients)
|
|
|
|
if portal.IsPrivateChat() {
|
|
portal.log.Debug().Msgf("Portal is private chat, updating direct chats: %s", portal.MXID)
|
|
puppet := user.bridge.GetPuppetBySignalID(portal.Receiver)
|
|
if puppet == nil {
|
|
portal.log.Error().Msgf("Failed to find puppet for portal receiver %s", portal.Receiver)
|
|
return nil
|
|
}
|
|
|
|
chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}
|
|
user.UpdateDirectChats(chats)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (portal *Portal) UpdateInfo(user *User, meta *any) *any {
|
|
return nil
|
|
}
|
|
|
|
// ** Portal loading and fetching **
|
|
var (
|
|
portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
|
|
)
|
|
|
|
func (br *SignalBridge) loadPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey) *Portal {
|
|
if dbPortal == nil {
|
|
if key == nil {
|
|
return nil
|
|
}
|
|
|
|
dbPortal = br.DB.Portal.New()
|
|
dbPortal.PortalKey = *key
|
|
err := dbPortal.Insert(ctx)
|
|
if err != nil {
|
|
br.ZLog.Err(err).Msg("Failed to insert new portal")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
portal := br.NewPortal(dbPortal)
|
|
|
|
br.portalsByID[portal.PortalKey] = portal
|
|
if portal.MXID != "" {
|
|
br.portalsByMXID[portal.MXID] = portal
|
|
}
|
|
|
|
return portal
|
|
}
|
|
|
|
func (br *SignalBridge) GetPortalByMXID(mxid id.RoomID) *Portal {
|
|
br.portalsLock.Lock()
|
|
defer br.portalsLock.Unlock()
|
|
|
|
portal, ok := br.portalsByMXID[mxid]
|
|
if !ok {
|
|
dbPortal, err := br.DB.Portal.GetByMXID(context.TODO(), mxid)
|
|
if err != nil {
|
|
br.ZLog.Err(err).Msg("Failed to get portal from database")
|
|
return nil
|
|
}
|
|
return br.loadPortal(context.TODO(), dbPortal, nil)
|
|
}
|
|
|
|
return portal
|
|
}
|
|
|
|
func (br *SignalBridge) GetPortalByChatID(key database.PortalKey) *Portal {
|
|
br.portalsLock.Lock()
|
|
defer br.portalsLock.Unlock()
|
|
// If this PortalKey is for a group, Receiver should be empty
|
|
if key.UserID() == uuid.Nil {
|
|
key.Receiver = uuid.Nil
|
|
}
|
|
portal, ok := br.portalsByID[key]
|
|
if !ok {
|
|
dbPortal, err := br.DB.Portal.GetByChatID(context.TODO(), key)
|
|
if err != nil {
|
|
br.ZLog.Err(err).Msg("Failed to get portal from database")
|
|
return nil
|
|
}
|
|
return br.loadPortal(context.TODO(), dbPortal, &key)
|
|
}
|
|
return portal
|
|
}
|
|
|
|
func (portal *Portal) getBridgeInfoStateKey() string {
|
|
return fmt.Sprintf("net.maunium.signal://signal/%s", portal.ChatID)
|
|
}
|
|
|
|
// ** DisappearingPortal interface **
|
|
func (portal *Portal) ScheduleDisappearing() {
|
|
portal.bridge.disappearingMessagesManager.ScheduleDisappearingForRoom(context.TODO(), portal.MXID)
|
|
}
|
|
|
|
func (portal *Portal) addToPersonalSpace(ctx context.Context, user *User) bool {
|
|
spaceID := user.GetSpaceRoom()
|
|
if len(spaceID) == 0 || user.IsInSpace(ctx, portal.PortalKey) {
|
|
return false
|
|
}
|
|
_, err := portal.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
|
|
Via: []string{portal.bridge.Config.Homeserver.Domain},
|
|
})
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).
|
|
Str("user_id", user.MXID.String()).
|
|
Str("space_id", spaceID.String()).
|
|
Msg("Failed to add room to user's personal filtering space")
|
|
return false
|
|
} else {
|
|
zerolog.Ctx(ctx).Debug().
|
|
Str("user_id", user.MXID.String()).
|
|
Str("space_id", spaceID.String()).
|
|
Msg("Added room to user's personal filtering space")
|
|
user.MarkInSpace(ctx, portal.PortalKey)
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) HasRelaybot() bool {
|
|
return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0
|
|
}
|
|
|
|
func (portal *Portal) addRelaybotFormat(userID id.UserID, evt *event.Event, content *event.MessageEventContent) bool {
|
|
member := portal.MainIntent().Member(portal.MXID, userID)
|
|
if member == nil {
|
|
member = &event.MemberEventContent{}
|
|
}
|
|
// Stickers can't have captions, so force them into images when relaying
|
|
if evt.Type == event.EventSticker {
|
|
content.MsgType = event.MsgImage
|
|
evt.Type = event.EventMessage
|
|
}
|
|
content.EnsureHasHTML()
|
|
data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member)
|
|
if err != nil {
|
|
portal.log.Err(err).Msg("Failed to apply relaybot format")
|
|
}
|
|
content.FormattedBody = data
|
|
// Force FileName field so the formatted body is used as a caption
|
|
if content.FileName == "" {
|
|
content.FileName = content.Body
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (portal *Portal) Delete() {
|
|
err := portal.Portal.Delete(context.TODO())
|
|
if err != nil {
|
|
portal.log.Err(err).Msg("Failed to delete portal from db")
|
|
}
|
|
portal.bridge.portalsLock.Lock()
|
|
delete(portal.bridge.portalsByID, portal.PortalKey)
|
|
if len(portal.MXID) > 0 {
|
|
delete(portal.bridge.portalsByMXID, portal.MXID)
|
|
}
|
|
if portal.Receiver == uuid.Nil {
|
|
portal.bridge.usersLock.Lock()
|
|
for _, user := range portal.bridge.usersBySignalID {
|
|
user.RemoveInSpaceCache(portal.PortalKey)
|
|
}
|
|
portal.bridge.usersLock.Unlock()
|
|
} else {
|
|
user := portal.bridge.GetUserBySignalID(portal.Receiver)
|
|
if user != nil {
|
|
user.RemoveInSpaceCache(portal.PortalKey)
|
|
}
|
|
}
|
|
portal.bridge.portalsLock.Unlock()
|
|
}
|
|
|
|
func (portal *Portal) Cleanup(puppetsOnly bool) {
|
|
portal.bridge.CleanupRoom(&portal.log, portal.MainIntent(), portal.MXID, puppetsOnly)
|
|
}
|
|
|
|
func (br *SignalBridge) CleanupRoom(log *zerolog.Logger, intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool) {
|
|
if len(mxid) == 0 {
|
|
return
|
|
}
|
|
if br.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
|
|
err := intent.BeeperDeleteRoom(mxid)
|
|
if err == nil || errors.Is(err, mautrix.MNotFound) {
|
|
return
|
|
}
|
|
log.Warn().Err(err).Msg("Failed to delete room using beeper yeet endpoint, falling back to normal behavior")
|
|
}
|
|
members, err := intent.JoinedMembers(mxid)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get portal members for cleanup")
|
|
return
|
|
}
|
|
for member := range members.Joined {
|
|
if member == intent.UserID {
|
|
continue
|
|
}
|
|
puppet := br.GetPuppetByMXID(member)
|
|
if puppet != nil {
|
|
_, err = puppet.DefaultIntent().LeaveRoom(mxid)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to leave as puppet while cleaning up portal")
|
|
}
|
|
} else if !puppetsOnly {
|
|
_, err = intent.KickUser(mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to kick user while cleaning up portal")
|
|
}
|
|
}
|
|
}
|
|
_, err = intent.LeaveRoom(mxid)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to leave room while cleaning up portal")
|
|
}
|
|
}
|