// 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 . 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("
  • %s
  • ", 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("

    %s

      %s

    (This message is a poll. Please open Signal to vote.)

    ", 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, }, }, } }