1
0
Fork 0
mirror of https://github.com/mautrix/signal.git synced 2026-05-15 05:36:53 -04:00
mautrix-signal/pkg/msgconv/from-signal.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,
},
},
}
}