2024-01-02 18:58:40 +02:00
// 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 (
2024-01-13 18:21:09 +02:00
"bytes"
2024-01-02 18:58:40 +02:00
"context"
2024-01-13 18:21:09 +02:00
"encoding/base64"
2025-01-19 13:26:23 +02:00
"errors"
2024-01-02 18:58:40 +02:00
"fmt"
2026-03-06 00:12:23 +02:00
"io"
2024-01-02 18:58:40 +02:00
"net/http"
2026-03-06 00:12:23 +02:00
"os"
2025-10-29 14:36:15 +02:00
"strconv"
2024-01-02 18:58:40 +02:00
"strings"
"time"
2024-01-13 18:21:09 +02:00
"github.com/emersion/go-vcard"
2024-08-07 02:14:39 +03:00
"github.com/google/uuid"
2024-01-02 18:58:40 +02:00
"github.com/rs/zerolog"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg"
2024-08-07 02:14:39 +03:00
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
2024-01-02 18:58:40 +02:00
"maunium.net/go/mautrix/event"
2024-08-07 02:14:39 +03:00
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
2024-01-02 18:58:40 +02:00
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
2025-01-19 13:26:23 +02:00
var (
ErrAttachmentNotInBackup = errors . New ( "attachment not found in backup" )
ErrBackupNotSupported = errors . New ( "downloading attachments from server-side backup is not yet supported" )
)
2024-01-02 18:58:40 +02:00
func calculateLength ( dm * signalpb . DataMessage ) int {
if dm . GetFlags ( ) & uint32 ( signalpb . DataMessage_EXPIRATION_TIMER_UPDATE ) != 0 {
2024-01-02 21:17:38 +02:00
return 1
2024-01-02 18:58:40 +02:00
}
2025-10-29 14:36:15 +02:00
if dm . Sticker != nil || dm . PollVote != nil || dm . PollCreate != nil || dm . PollTerminate != nil {
2024-01-02 21:17:38 +02:00
return 1
2024-01-02 18:58:40 +02:00
}
2024-01-02 21:17:38 +02:00
length := len ( dm . Attachments ) + len ( dm . Contact )
2024-01-02 18:58:40 +02:00
if dm . Body != nil {
length ++
}
if dm . Payment != nil {
length ++
}
2024-01-02 21:27:11 +02:00
if dm . GiftBadge != nil {
length ++
}
2024-01-02 18:58:40 +02:00
if length == 0 && dm . GetRequiredProtocolVersion ( ) > uint32 ( signalpb . DataMessage_CURRENT ) {
length = 1
}
return length
}
func CanConvertSignal ( dm * signalpb . DataMessage ) bool {
return calculateLength ( dm ) > 0
}
2024-12-20 13:18:52 +02:00
const ViewOnceDisappearTimer = 5 * time . Minute
2026-03-06 00:12:23 +02:00
const matrixTextMaxLength = 30000 // approximate value to avoid hitting 64 KiB PDU size limit with HTML duplication
2024-12-20 13:18:52 +02:00
2024-08-07 02:14:39 +03:00
func ( mc * MessageConverter ) ToMatrix (
ctx context . Context ,
client * signalmeow . Client ,
portal * bridgev2 . Portal ,
2025-10-29 14:36:15 +02:00
sender uuid . UUID ,
2024-08-07 02:14:39 +03:00
intent bridgev2 . MatrixAPI ,
dm * signalpb . DataMessage ,
2025-01-18 15:17:30 +02:00
attMap AttachmentMap ,
2024-08-07 02:14:39 +03:00
) * 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 ) ) ,
2024-01-02 18:58:40 +02:00
}
if dm . GetFlags ( ) & uint32 ( signalpb . DataMessage_EXPIRATION_TIMER_UPDATE ) != 0 {
2025-09-17 15:04:27 +03:00
cm . Parts = append ( cm . Parts , mc . ConvertDisappearingTimerChangeToMatrix (
ctx , dm . GetExpireTimer ( ) , dm . ExpireTimerVersion , time . UnixMilli ( int64 ( dm . GetTimestamp ( ) ) ) , attMap != nil ,
) )
2024-01-02 21:17:38 +02:00
// Don't allow any other parts in a disappearing timer change message
return cm
}
2024-08-07 02:14:39 +03:00
if dm . GetExpireTimer ( ) > 0 {
2025-08-26 17:23:55 +03:00
cm . Disappear . Type = event . DisappearingTypeAfterRead
2024-08-07 02:14:39 +03:00
cm . Disappear . Timer = time . Duration ( dm . GetExpireTimer ( ) ) * time . Second
}
2024-01-02 21:17:38 +02:00
if dm . Sticker != nil {
2025-01-18 15:17:30 +02:00
cm . Parts = append ( cm . Parts , mc . convertStickerToMatrix ( ctx , dm . Sticker , attMap ) )
2024-01-02 21:17:38 +02:00
// Don't allow any other parts in a sticker message
return cm
2024-01-02 18:58:40 +02:00
}
2025-10-29 14:36:15 +02:00
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
}
2024-01-02 18:58:40 +02:00
for i , att := range dm . GetAttachments ( ) {
2026-03-06 00:12:23 +02:00
if att . GetContentType ( ) != "text/x-signal-plain" || att . GetSize ( ) > matrixTextMaxLength {
2025-01-18 15:17:30 +02:00
cm . Parts = append ( cm . Parts , mc . convertAttachmentToMatrix ( ctx , i , att , attMap ) )
2024-05-01 10:17:07 +02:00
} else {
2025-01-18 15:17:30 +02:00
longBody , err := mc . downloadSignalLongText ( ctx , att , attMap )
2024-05-01 10:17:07 +02:00
if err == nil {
dm . Body = longBody
} else {
zerolog . Ctx ( ctx ) . Err ( err ) . Msg ( "Failed to download Signal long text" )
}
}
2024-01-02 18:58:40 +02:00
}
for _ , contact := range dm . GetContact ( ) {
2025-01-18 15:17:30 +02:00
cm . Parts = append ( cm . Parts , mc . convertContactToMatrix ( ctx , contact , attMap ) )
2024-01-02 18:58:40 +02:00
}
if dm . Payment != nil {
cm . Parts = append ( cm . Parts , mc . convertPaymentToMatrix ( ctx , dm . Payment ) )
}
2024-01-02 21:27:11 +02:00
if dm . GiftBadge != nil {
cm . Parts = append ( cm . Parts , mc . convertGiftBadgeToMatrix ( ctx , dm . GiftBadge ) )
}
2024-01-02 18:58:40 +02:00
if dm . Body != nil {
2025-01-18 15:17:30 +02:00
cm . Parts = append ( cm . Parts , mc . convertTextToMatrix ( ctx , dm , attMap ) )
2024-01-02 18:58:40 +02:00
}
if len ( cm . Parts ) == 0 && dm . GetRequiredProtocolVersion ( ) > uint32 ( signalpb . DataMessage_CURRENT ) {
2024-08-07 02:14:39 +03:00
cm . Parts = append ( cm . Parts , & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : "The bridge does not support this message type yet." ,
} ,
} )
}
2024-12-20 13:18:52 +02:00
if dm . GetIsViewOnce ( ) && mc . DisappearViewOnce && ( cm . Disappear . Timer == 0 || cm . Disappear . Timer > ViewOnceDisappearTimer ) {
2025-08-26 17:23:55 +03:00
cm . Disappear . Type = event . DisappearingTypeAfterRead
2024-12-20 13:18:52 +02:00
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." ,
} ,
} )
}
2024-08-07 02:14:39 +03:00
cm . MergeCaption ( )
for i , part := range cm . Parts {
part . ID = signalid . MakeMessagePartID ( i )
part . DBMetadata = & signalid . MessageMetadata {
ContainsAttachments : len ( dm . GetAttachments ( ) ) > 0 ,
2024-01-02 18:58:40 +02:00
}
2024-08-07 02:14:39 +03:00
}
if dm . Quote != nil {
2026-02-23 15:11:56 +02:00
authorACI , err := signalmeow . ParseStringOrBinaryUUID ( dm . Quote . GetAuthorAci ( ) , dm . Quote . GetAuthorAciBinary ( ) )
2024-08-07 02:14:39 +03:00
if err != nil {
2026-03-19 17:59:15 +02:00
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" )
2024-08-07 02:14:39 +03:00
} else {
cm . ReplyTo = & networkid . MessageOptionalPartID {
MessageID : signalid . MakeMessageID ( authorACI , dm . Quote . GetId ( ) ) ,
2024-01-02 18:58:40 +02:00
}
}
}
return cm
}
2025-09-17 15:04:27 +03:00
func ( mc * MessageConverter ) ConvertDisappearingTimerChangeToMatrix (
ctx context . Context , timer uint32 , timerVersion * uint32 , ts time . Time , isBackfill bool ,
) * bridgev2 . ConvertedMessagePart {
2025-09-09 16:27:03 +03:00
portal := getPortal ( ctx )
setting := database . DisappearingSetting {
Timer : time . Duration ( timer ) * time . Second ,
Type : event . DisappearingTypeAfterRead ,
}
if timer == 0 {
setting . Type = ""
}
2024-08-07 02:14:39 +03:00
part := & bridgev2 . ConvertedMessagePart {
2024-09-25 16:15:35 +03:00
Type : event . EventMessage ,
Content : bridgev2 . DisappearingMessageNotice ( time . Duration ( timer ) * time . Second , false ) ,
2025-09-09 16:27:03 +03:00
Extra : map [ string ] any {
"com.beeper.action_message" : map [ string ] any {
"type" : "disappearing_timer" ,
"timer" : setting . Timer . Milliseconds ( ) ,
"timer_type" : setting . Type ,
"implicit" : false ,
2025-09-17 15:04:27 +03:00
"backfill" : isBackfill ,
2025-09-09 16:27:03 +03:00
} ,
} ,
DontBridge : setting == portal . Disappear ,
2024-01-02 18:58:40 +02:00
}
2025-09-17 15:04:27 +03:00
if isBackfill {
return part
}
2025-08-26 17:23:55 +03:00
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 ,
} )
2024-01-02 18:58:40 +02:00
return part
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) convertTextToMatrix ( ctx context . Context , dm * signalpb . DataMessage , attMap AttachmentMap ) * bridgev2 . ConvertedMessagePart {
2024-03-12 14:57:00 -06:00
content := signalfmt . Parse ( ctx , dm . GetBody ( ) , dm . GetBodyRanges ( ) , mc . SignalFmtParams )
2024-01-02 21:15:17 +02:00
extra := map [ string ] any { }
if len ( dm . Preview ) > 0 {
2025-01-18 15:17:30 +02:00
content . BeeperLinkPreviews = mc . convertURLPreviewsToBeeper ( ctx , dm . Preview , attMap )
2024-01-02 21:15:17 +02:00
}
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : content ,
2024-01-02 21:15:17 +02:00
Extra : extra ,
2024-01-02 18:58:40 +02:00
}
}
2024-08-07 02:14:39 +03:00
func ( mc * MessageConverter ) convertPaymentToMatrix ( _ context . Context , payment * signalpb . DataMessage_Payment ) * bridgev2 . ConvertedMessagePart {
return & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : "Payments are not yet supported" ,
} ,
Extra : map [ string ] any {
"fi.mau.signal.payment" : payment ,
} ,
}
}
2024-08-07 02:14:39 +03:00
func ( mc * MessageConverter ) convertGiftBadgeToMatrix ( _ context . Context , giftBadge * signalpb . DataMessage_GiftBadge ) * bridgev2 . ConvertedMessagePart {
return & bridgev2 . ConvertedMessagePart {
2024-01-02 21:27:11 +02:00
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 ,
} ,
}
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) convertContactToVCard ( ctx context . Context , contact * signalpb . DataMessage_Contact , attMap AttachmentMap ) vcard . Card {
2024-01-13 18:21:09 +02:00
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 ( ) ,
} )
}
2024-10-15 17:08:56 +03:00
if name . GetNickname ( ) != "" {
card . SetValue ( vcard . FieldNickname , name . GetNickname ( ) )
2024-01-13 18:21:09 +02:00
}
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 {
2026-03-06 00:12:23 +02:00
avatarData , err := mc . downloadAttachment ( ctx , contact . GetAvatar ( ) . GetAvatar ( ) , attMap , nil )
2024-01-13 18:21:09 +02:00
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
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) convertContactToMatrix ( ctx context . Context , contact * signalpb . DataMessage_Contact , attMap AttachmentMap ) * bridgev2 . ConvertedMessagePart {
card := mc . convertContactToVCard ( ctx , contact , attMap )
2024-01-13 18:21:09 +02:00
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" )
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-13 18:21:09 +02:00
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : "Failed to encode vCard" ,
} ,
Extra : extraData ,
}
}
data := buf . Bytes ( )
2024-10-15 17:08:56 +03:00
displayName := contact . GetName ( ) . GetNickname ( )
2024-01-13 18:21:09 +02:00
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 ) ,
2024-01-02 18:58:40 +02:00
} ,
}
2024-08-07 02:14:39 +03:00
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 ,
}
2024-01-13 18:21:09 +02:00
}
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-13 18:21:09 +02:00
Type : event . EventMessage ,
Content : content ,
Extra : extraData ,
}
2024-01-02 18:58:40 +02:00
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) convertAttachmentToMatrix ( ctx context . Context , index int , att * signalpb . AttachmentPointer , attMap AttachmentMap ) * bridgev2 . ConvertedMessagePart {
part , err := mc . reuploadAttachment ( ctx , att , attMap )
2024-01-02 18:58:40 +02:00
if err != nil {
2025-01-19 13:26:23 +02:00
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" ,
} ,
}
}
2024-01-02 18:58:40 +02:00
zerolog . Ctx ( ctx ) . Err ( err ) . Int ( "attachment_index" , index ) . Msg ( "Failed to handle attachment" )
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : fmt . Sprintf ( "Failed to handle attachment %s: %v" , att . GetFileName ( ) , err ) ,
} ,
}
}
return part
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) convertStickerToMatrix ( ctx context . Context , sticker * signalpb . DataMessage_Sticker , attMap AttachmentMap ) * bridgev2 . ConvertedMessagePart {
converted , err := mc . reuploadAttachment ( ctx , sticker . GetData ( ) , attMap )
2024-01-02 18:58:40 +02:00
if err != nil {
zerolog . Ctx ( ctx ) . Err ( err ) . Msg ( "Failed to handle sticker" )
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : fmt . Sprintf ( "Failed to handle sticker: %v" , err ) ,
} ,
}
}
2025-10-27 18:07:14 +02:00
// 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
2024-01-03 00:08:36 +02:00
if converted . Content . Info . Width == 512 && converted . Content . Info . Height == 512 {
2025-10-27 18:07:14 +02:00
converted . Content . Info . Width = 200
converted . Content . Info . Height = 200
2024-01-03 00:08:36 +02:00
}
2024-01-02 18:58:40 +02:00
converted . Content . Body = sticker . GetEmoji ( )
converted . Type = event . EventSticker
converted . Content . MsgType = ""
2024-08-11 17:01:05 +03:00
if converted . Extra == nil {
converted . Extra = map [ string ] any { }
}
2024-01-02 18:58:40 +02:00
// 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
}
2025-01-18 15:17:30 +02:00
func ( mc * MessageConverter ) downloadSignalLongText ( ctx context . Context , att * signalpb . AttachmentPointer , attMap AttachmentMap ) ( * string , error ) {
2026-03-06 00:12:23 +02:00
data , err := mc . downloadAttachment ( ctx , att , attMap , nil )
2024-05-01 10:17:07 +02:00
if err != nil {
2025-01-19 13:26:23 +02:00
return nil , err
2024-05-01 10:17:07 +02:00
}
longBody := string ( data )
return & longBody , nil
}
2025-07-28 15:59:44 +03:00
func checkIfAttachmentExists ( att * signalpb . AttachmentPointer , attMap AttachmentMap ) error {
2025-01-18 15:17:30 +02:00
if att . AttachmentIdentifier == nil {
2025-04-15 15:22:31 +03:00
if len ( att . GetClientUuid ( ) ) != 16 {
2025-07-28 15:59:44 +03:00
return fmt . Errorf ( "no attachment identifier found" )
2025-01-18 15:17:30 +02:00
}
2025-04-15 15:22:31 +03:00
target , ok := attMap [ uuid . UUID ( att . GetClientUuid ( ) ) ]
2025-01-18 15:17:30 +02:00
if ! ok {
2025-07-28 15:59:44 +03:00
return fmt . Errorf ( "no attachment identifier and attachment not found in map" )
} else if target == nil || target . MediaTierCdnNumber == nil {
return ErrAttachmentNotInBackup
2025-01-18 15:17:30 +02:00
} else {
// TODO add support for downloading attachments from backup
2025-07-28 15:59:44 +03:00
return ErrBackupNotSupported
2025-01-18 15:17:30 +02:00
}
}
2025-07-28 15:59:44 +03:00
return nil
}
2026-03-06 00:12:23 +02:00
func ( mc * MessageConverter ) downloadAttachment (
ctx context . Context , att * signalpb . AttachmentPointer , attMap AttachmentMap , into * os . File ,
) ( [ ] byte , error ) {
2025-07-28 15:59:44 +03:00
if err := checkIfAttachmentExists ( att , attMap ) ; err != nil {
return nil , err
}
2025-07-28 16:17:55 +03:00
var plaintextHash [ ] byte
if len ( att . GetClientUuid ( ) ) == 16 {
target , ok := attMap [ uuid . UUID ( att . GetClientUuid ( ) ) ]
if ok {
plaintextHash = target . GetPlaintextHash ( )
}
}
2026-03-06 00:12:23 +02:00
return signalmeow . DownloadAttachmentWithPointer ( ctx , att , plaintextHash , into )
2025-01-18 15:17:30 +02:00
}
func ( mc * MessageConverter ) reuploadAttachment ( ctx context . Context , att * signalpb . AttachmentPointer , attMap AttachmentMap ) ( * bridgev2 . ConvertedMessagePart , error ) {
2024-08-07 02:14:39 +03:00
content := & event . MessageEventContent {
2026-03-06 00:12:23 +02:00
Body : att . GetFileName ( ) ,
2024-08-07 02:14:39 +03:00
Info : & event . FileInfo {
2026-03-06 00:12:23 +02:00
MimeType : att . GetContentType ( ) ,
Width : int ( att . GetWidth ( ) ) ,
Height : int ( att . GetHeight ( ) ) ,
Size : int ( att . GetSize ( ) ) ,
2024-08-07 02:14:39 +03:00
} ,
}
2025-07-28 15:59:44 +03:00
if err := checkIfAttachmentExists ( att , attMap ) ; err != nil {
return nil , err
} else if mc . DirectMedia {
2025-07-28 16:17:55 +03:00
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
}
}
2025-05-09 13:57:28 +03:00
mediaID , err := signalid . DirectMediaAttachment {
2025-07-28 16:17:55 +03:00
CDNID : att . GetCdnId ( ) ,
CDNKey : att . GetCdnKey ( ) ,
CDNNumber : att . GetCdnNumber ( ) ,
Key : att . Key ,
Digest : digest ,
PlaintextDigest : plaintextDigest ,
Size : att . GetSize ( ) ,
2025-05-09 13:57:28 +03:00
} . AsMediaID ( )
2024-01-02 18:58:40 +02:00
if err != nil {
2025-05-09 13:57:28 +03:00
return nil , err
}
content . URL , err = mc . Bridge . Matrix . GenerateContentURI ( ctx , mediaID )
} else {
2026-03-06 00:12:23 +02:00
err = mc . actuallyReuploadAttachment ( ctx , content , att , attMap )
2025-05-09 13:57:28 +03:00
if err != nil {
return nil , err
2024-01-02 18:58:40 +02:00
}
}
if att . GetBlurHash ( ) != "" {
2024-01-03 14:55:20 +02:00
content . Info . Blurhash = att . GetBlurHash ( )
content . Info . AnoaBlurhash = att . GetBlurHash ( )
2024-01-02 18:58:40 +02:00
}
2026-03-06 00:12:23 +02:00
switch strings . Split ( content . Info . MimeType , "/" ) [ 0 ] {
2024-01-02 18:58:40 +02:00
case "image" :
content . MsgType = event . MsgImage
case "video" :
content . MsgType = event . MsgVideo
case "audio" :
content . MsgType = event . MsgAudio
default :
content . MsgType = event . MsgFile
}
2025-01-14 14:09:43 +02:00
var extra map [ string ] any
2025-07-28 16:52:46 +03:00
if att . GetFlags ( ) & uint32 ( signalpb . AttachmentPointer_GIF ) != 0 {
2025-01-14 14:09:43 +02:00
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 ,
} ,
}
}
2024-01-02 18:58:40 +02:00
if content . Body == "" {
2026-03-06 00:12:23 +02:00
content . Body = strings . TrimPrefix ( string ( content . MsgType ) , "m." ) + exmime . ExtensionFromMimetype ( content . Info . MimeType )
2024-01-02 18:58:40 +02:00
}
2024-08-07 02:14:39 +03:00
return & bridgev2 . ConvertedMessagePart {
2024-01-02 18:58:40 +02:00
Type : event . EventMessage ,
Content : content ,
2025-01-14 14:09:43 +02:00
Extra : extra ,
2024-01-02 18:58:40 +02:00
} , nil
}
2025-10-29 14:36:15 +02:00
2026-03-06 00:12:23 +02:00
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
}
2025-10-29 14:36:15 +02:00
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
2025-12-10 18:30:00 +02:00
} else if pollMessage == nil {
zerolog . Ctx ( ctx ) . Warn ( ) . Msg ( "Poll vote target message not found" )
return invalidPollVote
2025-10-29 14:36:15 +02:00
}
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 ,
} ,
} ,
}
}