mirror of
https://github.com/mautrix/signal.git
synced 2026-05-15 05:36:53 -04:00
789 lines
26 KiB
Go
789 lines
26 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 msgconv
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-vcard"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/exmime"
|
|
"go.mau.fi/util/ffmpeg"
|
|
"maunium.net/go/mautrix/bridgev2"
|
|
"maunium.net/go/mautrix/bridgev2/database"
|
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
|
"maunium.net/go/mautrix/event"
|
|
|
|
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
|
|
"go.mau.fi/mautrix-signal/pkg/signalid"
|
|
"go.mau.fi/mautrix-signal/pkg/signalmeow"
|
|
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
|
|
)
|
|
|
|
var (
|
|
ErrAttachmentNotInBackup = errors.New("attachment not found in backup")
|
|
ErrBackupNotSupported = errors.New("downloading attachments from server-side backup is not yet supported")
|
|
)
|
|
|
|
func calculateLength(dm *signalpb.DataMessage) int {
|
|
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
|
|
return 1
|
|
}
|
|
if dm.Sticker != nil || dm.PollVote != nil || dm.PollCreate != nil || dm.PollTerminate != nil {
|
|
return 1
|
|
}
|
|
length := len(dm.Attachments) + len(dm.Contact)
|
|
if dm.Body != nil {
|
|
length++
|
|
}
|
|
if dm.Payment != nil {
|
|
length++
|
|
}
|
|
if dm.GiftBadge != nil {
|
|
length++
|
|
}
|
|
if length == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) {
|
|
length = 1
|
|
}
|
|
return length
|
|
}
|
|
|
|
func CanConvertSignal(dm *signalpb.DataMessage) bool {
|
|
return calculateLength(dm) > 0
|
|
}
|
|
|
|
const ViewOnceDisappearTimer = 5 * time.Minute
|
|
const matrixTextMaxLength = 30000 // approximate value to avoid hitting 64 KiB PDU size limit with HTML duplication
|
|
|
|
func (mc *MessageConverter) ToMatrix(
|
|
ctx context.Context,
|
|
client *signalmeow.Client,
|
|
portal *bridgev2.Portal,
|
|
sender uuid.UUID,
|
|
intent bridgev2.MatrixAPI,
|
|
dm *signalpb.DataMessage,
|
|
attMap AttachmentMap,
|
|
) *bridgev2.ConvertedMessage {
|
|
ctx = context.WithValue(ctx, contextKeyClient, client)
|
|
ctx = context.WithValue(ctx, contextKeyPortal, portal)
|
|
ctx = context.WithValue(ctx, contextKeyIntent, intent)
|
|
cm := &bridgev2.ConvertedMessage{
|
|
ReplyTo: nil,
|
|
ThreadRoot: nil,
|
|
Parts: make([]*bridgev2.ConvertedMessagePart, 0, calculateLength(dm)),
|
|
}
|
|
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
|
|
cm.Parts = append(cm.Parts, mc.ConvertDisappearingTimerChangeToMatrix(
|
|
ctx, dm.GetExpireTimer(), dm.ExpireTimerVersion, time.UnixMilli(int64(dm.GetTimestamp())), attMap != nil,
|
|
))
|
|
// Don't allow any other parts in a disappearing timer change message
|
|
return cm
|
|
}
|
|
if dm.GetExpireTimer() > 0 {
|
|
cm.Disappear.Type = event.DisappearingTypeAfterRead
|
|
cm.Disappear.Timer = time.Duration(dm.GetExpireTimer()) * time.Second
|
|
}
|
|
if dm.Sticker != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertStickerToMatrix(ctx, dm.Sticker, attMap))
|
|
// Don't allow any other parts in a sticker message
|
|
return cm
|
|
}
|
|
if dm.PollVote != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertPollVoteToMatrix(ctx, dm.PollVote))
|
|
return cm
|
|
}
|
|
if dm.PollCreate != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertPollCreateToMatrix(dm.PollCreate))
|
|
return cm
|
|
}
|
|
if dm.PollTerminate != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertPollTerminateToMatrix(ctx, sender, dm.PollTerminate))
|
|
return cm
|
|
}
|
|
for i, att := range dm.GetAttachments() {
|
|
if att.GetContentType() != "text/x-signal-plain" || att.GetSize() > matrixTextMaxLength {
|
|
cm.Parts = append(cm.Parts, mc.convertAttachmentToMatrix(ctx, i, att, attMap))
|
|
} else {
|
|
longBody, err := mc.downloadSignalLongText(ctx, att, attMap)
|
|
if err == nil {
|
|
dm.Body = longBody
|
|
} else {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to download Signal long text")
|
|
}
|
|
}
|
|
}
|
|
for _, contact := range dm.GetContact() {
|
|
cm.Parts = append(cm.Parts, mc.convertContactToMatrix(ctx, contact, attMap))
|
|
}
|
|
if dm.Payment != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertPaymentToMatrix(ctx, dm.Payment))
|
|
}
|
|
if dm.GiftBadge != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertGiftBadgeToMatrix(ctx, dm.GiftBadge))
|
|
}
|
|
if dm.Body != nil {
|
|
cm.Parts = append(cm.Parts, mc.convertTextToMatrix(ctx, dm, attMap))
|
|
}
|
|
if len(cm.Parts) == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) {
|
|
cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "The bridge does not support this message type yet.",
|
|
},
|
|
})
|
|
}
|
|
if dm.GetIsViewOnce() && mc.DisappearViewOnce && (cm.Disappear.Timer == 0 || cm.Disappear.Timer > ViewOnceDisappearTimer) {
|
|
cm.Disappear.Type = event.DisappearingTypeAfterRead
|
|
cm.Disappear.Timer = ViewOnceDisappearTimer
|
|
cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: "This is a view-once message. It will disappear in 5 minutes.",
|
|
},
|
|
})
|
|
}
|
|
cm.MergeCaption()
|
|
for i, part := range cm.Parts {
|
|
part.ID = signalid.MakeMessagePartID(i)
|
|
part.DBMetadata = &signalid.MessageMetadata{
|
|
ContainsAttachments: len(dm.GetAttachments()) > 0,
|
|
}
|
|
}
|
|
if dm.Quote != nil {
|
|
authorACI, err := signalmeow.ParseStringOrBinaryUUID(dm.Quote.GetAuthorAci(), dm.Quote.GetAuthorAciBinary())
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).
|
|
Str("author_aci", dm.Quote.GetAuthorAci()).
|
|
Hex("author_aci_binary", dm.Quote.GetAuthorAciBinary()).
|
|
Msg("Failed to parse quote author ACI")
|
|
} else {
|
|
cm.ReplyTo = &networkid.MessageOptionalPartID{
|
|
MessageID: signalid.MakeMessageID(authorACI, dm.Quote.GetId()),
|
|
}
|
|
}
|
|
}
|
|
return cm
|
|
}
|
|
|
|
func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(
|
|
ctx context.Context, timer uint32, timerVersion *uint32, ts time.Time, isBackfill bool,
|
|
) *bridgev2.ConvertedMessagePart {
|
|
portal := getPortal(ctx)
|
|
setting := database.DisappearingSetting{
|
|
Timer: time.Duration(timer) * time.Second,
|
|
Type: event.DisappearingTypeAfterRead,
|
|
}
|
|
if timer == 0 {
|
|
setting.Type = ""
|
|
}
|
|
part := &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: bridgev2.DisappearingMessageNotice(time.Duration(timer)*time.Second, false),
|
|
Extra: map[string]any{
|
|
"com.beeper.action_message": map[string]any{
|
|
"type": "disappearing_timer",
|
|
"timer": setting.Timer.Milliseconds(),
|
|
"timer_type": setting.Type,
|
|
"implicit": false,
|
|
"backfill": isBackfill,
|
|
},
|
|
},
|
|
DontBridge: setting == portal.Disappear,
|
|
}
|
|
if isBackfill {
|
|
return part
|
|
}
|
|
portalMeta := portal.Metadata.(*signalid.PortalMetadata)
|
|
if timerVersion != nil && portalMeta.ExpirationTimerVersion > *timerVersion {
|
|
zerolog.Ctx(ctx).Warn().
|
|
Uint32("current_version", portalMeta.ExpirationTimerVersion).
|
|
Uint32("new_version", *timerVersion).
|
|
Msg("Ignoring outdated disappearing timer change")
|
|
part.Content.Body += " (change ignored)"
|
|
return part
|
|
}
|
|
if timerVersion != nil {
|
|
portalMeta.ExpirationTimerVersion = *timerVersion
|
|
} else {
|
|
portalMeta.ExpirationTimerVersion = 1
|
|
}
|
|
portal.UpdateDisappearingSetting(ctx, setting, bridgev2.UpdateDisappearingSettingOpts{
|
|
Sender: getIntent(ctx),
|
|
Timestamp: ts,
|
|
Save: true,
|
|
})
|
|
return part
|
|
}
|
|
|
|
func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage, attMap AttachmentMap) *bridgev2.ConvertedMessagePart {
|
|
content := signalfmt.Parse(ctx, dm.GetBody(), dm.GetBodyRanges(), mc.SignalFmtParams)
|
|
extra := map[string]any{}
|
|
if len(dm.Preview) > 0 {
|
|
content.BeeperLinkPreviews = mc.convertURLPreviewsToBeeper(ctx, dm.Preview, attMap)
|
|
}
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: content,
|
|
Extra: extra,
|
|
}
|
|
}
|
|
|
|
func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *signalpb.DataMessage_Payment) *bridgev2.ConvertedMessagePart {
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Payments are not yet supported",
|
|
},
|
|
Extra: map[string]any{
|
|
"fi.mau.signal.payment": payment,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mc *MessageConverter) convertGiftBadgeToMatrix(_ context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *bridgev2.ConvertedMessagePart {
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Gift badges are not yet supported",
|
|
},
|
|
Extra: map[string]any{
|
|
"fi.mau.signal.gift_badge": giftBadge,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (mc *MessageConverter) convertContactToVCard(ctx context.Context, contact *signalpb.DataMessage_Contact, attMap AttachmentMap) vcard.Card {
|
|
card := make(vcard.Card)
|
|
card.SetValue(vcard.FieldVersion, "4.0")
|
|
name := contact.GetName()
|
|
if name.GetFamilyName() != "" || name.GetGivenName() != "" {
|
|
card.SetName(&vcard.Name{
|
|
FamilyName: name.GetFamilyName(),
|
|
GivenName: name.GetGivenName(),
|
|
AdditionalName: name.GetMiddleName(),
|
|
HonorificPrefix: name.GetPrefix(),
|
|
HonorificSuffix: name.GetSuffix(),
|
|
})
|
|
}
|
|
if name.GetNickname() != "" {
|
|
card.SetValue(vcard.FieldNickname, name.GetNickname())
|
|
}
|
|
if contact.GetOrganization() != "" {
|
|
card.SetValue(vcard.FieldOrganization, contact.GetOrganization())
|
|
}
|
|
for _, addr := range contact.GetAddress() {
|
|
field := vcard.Field{
|
|
Value: strings.Join([]string{
|
|
addr.GetPobox(),
|
|
"", // extended address,
|
|
addr.GetStreet(),
|
|
addr.GetCity(),
|
|
addr.GetRegion(),
|
|
addr.GetPostcode(),
|
|
addr.GetCountry(),
|
|
// TODO put neighborhood somewhere?
|
|
}, ";"),
|
|
Params: make(vcard.Params),
|
|
}
|
|
if addr.GetLabel() != "" {
|
|
field.Params.Set("LABEL", addr.GetLabel())
|
|
}
|
|
field.Params.Set(vcard.ParamType, strings.ToLower(addr.GetType().String()))
|
|
card.Add(vcard.FieldAddress, &field)
|
|
}
|
|
for _, email := range contact.GetEmail() {
|
|
field := vcard.Field{
|
|
Value: email.GetValue(),
|
|
Params: make(vcard.Params),
|
|
}
|
|
field.Params.Set(vcard.ParamType, strings.ToLower(email.GetType().String()))
|
|
if email.GetLabel() != "" {
|
|
field.Params.Set("LABEL", email.GetLabel())
|
|
}
|
|
card.Add(vcard.FieldEmail, &field)
|
|
}
|
|
for _, phone := range contact.GetNumber() {
|
|
field := vcard.Field{
|
|
Value: phone.GetValue(),
|
|
Params: make(vcard.Params),
|
|
}
|
|
field.Params.Set(vcard.ParamType, strings.ToLower(phone.GetType().String()))
|
|
if phone.GetLabel() != "" {
|
|
field.Params.Set("LABEL", phone.GetLabel())
|
|
}
|
|
card.Add(vcard.FieldTelephone, &field)
|
|
}
|
|
if contact.GetAvatar().GetAvatar() != nil {
|
|
avatarData, err := mc.downloadAttachment(ctx, contact.GetAvatar().GetAvatar(), attMap, nil)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to download contact avatar")
|
|
} else {
|
|
mimeType := contact.GetAvatar().GetAvatar().GetContentType()
|
|
if mimeType == "" {
|
|
mimeType = http.DetectContentType(avatarData)
|
|
}
|
|
card.SetValue(vcard.FieldPhoto, fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(avatarData)))
|
|
}
|
|
}
|
|
return card
|
|
}
|
|
|
|
func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact, attMap AttachmentMap) *bridgev2.ConvertedMessagePart {
|
|
card := mc.convertContactToVCard(ctx, contact, attMap)
|
|
contact.Avatar = nil
|
|
extraData := map[string]any{
|
|
"fi.mau.signal.contact": contact,
|
|
}
|
|
var buf bytes.Buffer
|
|
err := vcard.NewEncoder(&buf).Encode(card)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to encode vCard")
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Failed to encode vCard",
|
|
},
|
|
Extra: extraData,
|
|
}
|
|
}
|
|
data := buf.Bytes()
|
|
displayName := contact.GetName().GetNickname()
|
|
if displayName == "" {
|
|
displayName = contact.GetName().GetGivenName()
|
|
if contact.GetName().GetFamilyName() != "" {
|
|
if displayName != "" {
|
|
displayName += " "
|
|
}
|
|
displayName += contact.GetName().GetFamilyName()
|
|
}
|
|
}
|
|
if displayName == "" {
|
|
displayName = "contact"
|
|
}
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgFile,
|
|
Body: displayName + ".vcf",
|
|
Info: &event.FileInfo{
|
|
MimeType: "text/vcf",
|
|
Size: len(data),
|
|
},
|
|
}
|
|
content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, content.Info.MimeType, content.Body)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to upload vCard")
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Failed to upload vCard",
|
|
},
|
|
Extra: extraData,
|
|
}
|
|
}
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: content,
|
|
Extra: extraData,
|
|
}
|
|
}
|
|
|
|
func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer, attMap AttachmentMap) *bridgev2.ConvertedMessagePart {
|
|
part, err := mc.reuploadAttachment(ctx, att, attMap)
|
|
if err != nil {
|
|
if (errors.Is(err, signalmeow.ErrAttachmentNotFound) || errors.Is(err, ErrAttachmentNotInBackup)) && attMap != nil {
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: fmt.Sprintf("Attachment no longer available %s", att.GetFileName()),
|
|
},
|
|
}
|
|
} else if errors.Is(err, ErrBackupNotSupported) {
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Downloading attachments from backup is not yet supported",
|
|
},
|
|
}
|
|
}
|
|
zerolog.Ctx(ctx).Err(err).Int("attachment_index", index).Msg("Failed to handle attachment")
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: fmt.Sprintf("Failed to handle attachment %s: %v", att.GetFileName(), err),
|
|
},
|
|
}
|
|
}
|
|
return part
|
|
}
|
|
|
|
func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker, attMap AttachmentMap) *bridgev2.ConvertedMessagePart {
|
|
converted, err := mc.reuploadAttachment(ctx, sticker.GetData(), attMap)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to handle sticker")
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: fmt.Sprintf("Failed to handle sticker: %v", err),
|
|
},
|
|
}
|
|
}
|
|
// Signal stickers are 512x512, so tell Matrix clients to render them as 200x200 to match Signal
|
|
// https://github.com/signalapp/Signal-Desktop/blob/v7.77.0-beta.1/ts/components/conversation/Message.dom.tsx#L135
|
|
if converted.Content.Info.Width == 512 && converted.Content.Info.Height == 512 {
|
|
converted.Content.Info.Width = 200
|
|
converted.Content.Info.Height = 200
|
|
}
|
|
converted.Content.Body = sticker.GetEmoji()
|
|
converted.Type = event.EventSticker
|
|
converted.Content.MsgType = ""
|
|
if converted.Extra == nil {
|
|
converted.Extra = map[string]any{}
|
|
}
|
|
// TODO fetch full pack metadata like the old bridge did?
|
|
converted.Extra["fi.mau.signal.sticker"] = map[string]any{
|
|
"id": sticker.GetStickerId(),
|
|
"emoji": sticker.GetEmoji(),
|
|
"pack": map[string]any{
|
|
"id": sticker.GetPackId(),
|
|
"key": sticker.GetPackKey(),
|
|
},
|
|
}
|
|
return converted
|
|
}
|
|
|
|
func (mc *MessageConverter) downloadSignalLongText(ctx context.Context, att *signalpb.AttachmentPointer, attMap AttachmentMap) (*string, error) {
|
|
data, err := mc.downloadAttachment(ctx, att, attMap, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
longBody := string(data)
|
|
return &longBody, nil
|
|
}
|
|
|
|
func checkIfAttachmentExists(att *signalpb.AttachmentPointer, attMap AttachmentMap) error {
|
|
if att.AttachmentIdentifier == nil {
|
|
if len(att.GetClientUuid()) != 16 {
|
|
return fmt.Errorf("no attachment identifier found")
|
|
}
|
|
target, ok := attMap[uuid.UUID(att.GetClientUuid())]
|
|
if !ok {
|
|
return fmt.Errorf("no attachment identifier and attachment not found in map")
|
|
} else if target == nil || target.MediaTierCdnNumber == nil {
|
|
return ErrAttachmentNotInBackup
|
|
} else {
|
|
// TODO add support for downloading attachments from backup
|
|
return ErrBackupNotSupported
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (mc *MessageConverter) downloadAttachment(
|
|
ctx context.Context, att *signalpb.AttachmentPointer, attMap AttachmentMap, into *os.File,
|
|
) ([]byte, error) {
|
|
if err := checkIfAttachmentExists(att, attMap); err != nil {
|
|
return nil, err
|
|
}
|
|
var plaintextHash []byte
|
|
if len(att.GetClientUuid()) == 16 {
|
|
target, ok := attMap[uuid.UUID(att.GetClientUuid())]
|
|
if ok {
|
|
plaintextHash = target.GetPlaintextHash()
|
|
}
|
|
}
|
|
return signalmeow.DownloadAttachmentWithPointer(ctx, att, plaintextHash, into)
|
|
}
|
|
|
|
func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer, attMap AttachmentMap) (*bridgev2.ConvertedMessagePart, error) {
|
|
content := &event.MessageEventContent{
|
|
Body: att.GetFileName(),
|
|
Info: &event.FileInfo{
|
|
MimeType: att.GetContentType(),
|
|
Width: int(att.GetWidth()),
|
|
Height: int(att.GetHeight()),
|
|
Size: int(att.GetSize()),
|
|
},
|
|
}
|
|
if err := checkIfAttachmentExists(att, attMap); err != nil {
|
|
return nil, err
|
|
} else if mc.DirectMedia {
|
|
digest := att.Digest
|
|
var plaintextDigest bool
|
|
if digest == nil && len(att.GetClientUuid()) == 16 {
|
|
locatorInfo, ok := attMap[uuid.UUID(att.GetClientUuid())]
|
|
if ok {
|
|
digest = locatorInfo.GetPlaintextHash()
|
|
plaintextDigest = true
|
|
}
|
|
}
|
|
mediaID, err := signalid.DirectMediaAttachment{
|
|
CDNID: att.GetCdnId(),
|
|
CDNKey: att.GetCdnKey(),
|
|
CDNNumber: att.GetCdnNumber(),
|
|
Key: att.Key,
|
|
Digest: digest,
|
|
PlaintextDigest: plaintextDigest,
|
|
Size: att.GetSize(),
|
|
}.AsMediaID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content.URL, err = mc.Bridge.Matrix.GenerateContentURI(ctx, mediaID)
|
|
} else {
|
|
err = mc.actuallyReuploadAttachment(ctx, content, att, attMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if att.GetBlurHash() != "" {
|
|
content.Info.Blurhash = att.GetBlurHash()
|
|
content.Info.AnoaBlurhash = att.GetBlurHash()
|
|
}
|
|
switch strings.Split(content.Info.MimeType, "/")[0] {
|
|
case "image":
|
|
content.MsgType = event.MsgImage
|
|
case "video":
|
|
content.MsgType = event.MsgVideo
|
|
case "audio":
|
|
content.MsgType = event.MsgAudio
|
|
default:
|
|
content.MsgType = event.MsgFile
|
|
}
|
|
var extra map[string]any
|
|
if att.GetFlags()&uint32(signalpb.AttachmentPointer_GIF) != 0 {
|
|
content.Info.MauGIF = true
|
|
extra = map[string]any{
|
|
"info": map[string]any{
|
|
"fi.mau.loop": true,
|
|
"fi.mau.autoplay": true,
|
|
"fi.mau.hide_controls": true,
|
|
"fi.mau.no_audio": true,
|
|
},
|
|
}
|
|
}
|
|
if content.Body == "" {
|
|
content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(content.Info.MimeType)
|
|
}
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventMessage,
|
|
Content: content,
|
|
Extra: extra,
|
|
}, nil
|
|
}
|
|
|
|
func (mc *MessageConverter) actuallyReuploadAttachment(
|
|
ctx context.Context,
|
|
content *event.MessageEventContent,
|
|
att *signalpb.AttachmentPointer,
|
|
attMap AttachmentMap,
|
|
) (err error) {
|
|
convertVoice := att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 && ffmpeg.Supported()
|
|
requireFile := convertVoice
|
|
content.URL, content.File, err = getIntent(ctx).UploadMediaStream(ctx, getPortal(ctx).MXID, int64(att.GetSize()), requireFile, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
|
|
osFile, ok := file.(*os.File)
|
|
inMemData, err := mc.downloadAttachment(ctx, att, attMap, osFile)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !ok {
|
|
if content.Info.MimeType == "" {
|
|
content.Info.MimeType = http.DetectContentType(inMemData)
|
|
}
|
|
_, err = file.Write(inMemData)
|
|
return &bridgev2.FileStreamResult{
|
|
FileName: content.Body,
|
|
MimeType: content.Info.MimeType,
|
|
}, err
|
|
}
|
|
if content.Info.MimeType == "" {
|
|
header := make([]byte, 512)
|
|
_, err = osFile.ReadAt(header, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read file header for MIME type detection: %w", err)
|
|
} else {
|
|
content.Info.MimeType = http.DetectContentType(header)
|
|
}
|
|
}
|
|
var replFile string
|
|
if att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 && ffmpeg.Supported() {
|
|
replFile, err = ffmpeg.ConvertPath(ctx, osFile.Name(), ".ogg", []string{}, []string{"-c:a", "libopus"}, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err)
|
|
}
|
|
if content.Body == "" {
|
|
content.Body = "Voice message.ogg"
|
|
} else {
|
|
content.Body += ".ogg"
|
|
}
|
|
content.Info.MimeType = "audio/ogg"
|
|
content.MSC3245Voice = &event.MSC3245Voice{}
|
|
// TODO include duration here (and in info) if there's some easy way to extract it with ffmpeg
|
|
//content.MSC1767Audio = &event.MSC1767Audio{}
|
|
}
|
|
return &bridgev2.FileStreamResult{
|
|
ReplacementFile: replFile,
|
|
FileName: content.Body,
|
|
MimeType: content.Info.MimeType,
|
|
}, nil
|
|
})
|
|
return
|
|
}
|
|
|
|
func (mc *MessageConverter) convertPollCreateToMatrix(create *signalpb.DataMessage_PollCreate) *bridgev2.ConvertedMessagePart {
|
|
evtType := event.EventMessage
|
|
if mc.ExtEvPolls {
|
|
evtType = event.EventUnstablePollStart
|
|
}
|
|
maxChoices := 1
|
|
if create.GetAllowMultiple() {
|
|
maxChoices = len(create.GetOptions())
|
|
}
|
|
msc3381Answers := make([]map[string]any, len(create.GetOptions()))
|
|
optionsListText := make([]string, len(create.GetOptions()))
|
|
optionsListHTML := make([]string, len(create.GetOptions()))
|
|
for i, option := range create.GetOptions() {
|
|
msc3381Answers[i] = map[string]any{
|
|
"id": strconv.Itoa(i),
|
|
"org.matrix.msc1767.text": option,
|
|
}
|
|
optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, option)
|
|
optionsListHTML[i] = fmt.Sprintf("<li>%s</li>", event.TextToHTML(option))
|
|
}
|
|
body := fmt.Sprintf("%s\n\n%s\n\n(This message is a poll. Please open Signal to vote.)", create.GetQuestion(), strings.Join(optionsListText, "\n"))
|
|
formattedBody := fmt.Sprintf("<p>%s</p><ol>%s</ol><p>(This message is a poll. Please open Signal to vote.)</p>", event.TextToHTML(create.GetQuestion()), strings.Join(optionsListHTML, ""))
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: evtType,
|
|
Content: &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: body,
|
|
Format: event.FormatHTML,
|
|
FormattedBody: formattedBody,
|
|
},
|
|
Extra: map[string]any{
|
|
"fi.mau.signal.poll": map[string]any{
|
|
"question": create.GetQuestion(),
|
|
"allow_multiple": create.GetAllowMultiple(),
|
|
"options": create.GetOptions(),
|
|
},
|
|
"org.matrix.msc1767.message": []map[string]any{
|
|
{"mimetype": "text/html", "body": formattedBody},
|
|
{"mimetype": "text/plain", "body": body},
|
|
},
|
|
"org.matrix.msc3381.poll.start": map[string]any{
|
|
"kind": "org.matrix.msc3381.poll.disclosed",
|
|
"max_selections": maxChoices,
|
|
"question": map[string]any{
|
|
"org.matrix.msc1767.text": create.GetQuestion(),
|
|
},
|
|
"answers": msc3381Answers,
|
|
},
|
|
},
|
|
DBMetadata: nil,
|
|
DontBridge: false,
|
|
}
|
|
}
|
|
|
|
func (mc *MessageConverter) convertPollTerminateToMatrix(ctx context.Context, senderACI uuid.UUID, terminate *signalpb.DataMessage_PollTerminate) *bridgev2.ConvertedMessagePart {
|
|
pollMessageID := signalid.MakeMessageID(senderACI, terminate.GetTargetSentTimestamp())
|
|
pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "")
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to get poll terminate target message")
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventUnstablePollEnd,
|
|
Content: &event.MessageEventContent{},
|
|
DontBridge: true,
|
|
}
|
|
}
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventUnstablePollEnd,
|
|
Content: &event.MessageEventContent{
|
|
RelatesTo: &event.RelatesTo{
|
|
Type: event.RelReference,
|
|
EventID: pollMessage.MXID,
|
|
},
|
|
},
|
|
Extra: map[string]any{
|
|
"org.matrix.msc3381.poll.end": map[string]any{},
|
|
},
|
|
}
|
|
}
|
|
|
|
var invalidPollVote = &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventUnstablePollResponse,
|
|
Content: &event.MessageEventContent{},
|
|
DontBridge: true,
|
|
}
|
|
|
|
func (mc *MessageConverter) convertPollVoteToMatrix(ctx context.Context, vote *signalpb.DataMessage_PollVote) *bridgev2.ConvertedMessagePart {
|
|
if len(vote.GetTargetAuthorAciBinary()) != 16 {
|
|
zerolog.Ctx(ctx).Debug().
|
|
Str("author_aci_b64", base64.StdEncoding.EncodeToString(vote.GetTargetAuthorAciBinary())).
|
|
Msg("Invalid author ACI in poll vote")
|
|
return invalidPollVote
|
|
}
|
|
pollMessageID := signalid.MakeMessageID(uuid.UUID(vote.GetTargetAuthorAciBinary()), vote.GetTargetSentTimestamp())
|
|
pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "")
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to get poll vote target message")
|
|
return invalidPollVote
|
|
} else if pollMessage == nil {
|
|
zerolog.Ctx(ctx).Warn().Msg("Poll vote target message not found")
|
|
return invalidPollVote
|
|
}
|
|
mxOptionIDs := pollMessage.Metadata.(*signalid.MessageMetadata).MatrixPollOptionIDs
|
|
optionIDs := make([]string, len(vote.GetOptionIndexes()))
|
|
for i, optionIndex := range vote.GetOptionIndexes() {
|
|
if int(optionIndex) < len(mxOptionIDs) {
|
|
optionIDs[i] = mxOptionIDs[optionIndex]
|
|
} else {
|
|
optionIDs[i] = strconv.Itoa(int(optionIndex))
|
|
}
|
|
}
|
|
return &bridgev2.ConvertedMessagePart{
|
|
Type: event.EventUnstablePollResponse,
|
|
Content: &event.MessageEventContent{
|
|
RelatesTo: &event.RelatesTo{
|
|
Type: event.RelReference,
|
|
EventID: pollMessage.MXID,
|
|
},
|
|
},
|
|
Extra: map[string]any{
|
|
"org.matrix.msc3381.poll.response": map[string]any{
|
|
"answers": optionIDs,
|
|
},
|
|
},
|
|
}
|
|
}
|