mautrix-signal/pkg/msgconv/from-signal.go

478 lines
15 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"
"fmt"
"net/http"
"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"
)
func calculateLength(dm *signalpb.DataMessage) int {
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
return 1
}
if dm.Sticker != 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
}
func (mc *MessageConverter) ToMatrix(
ctx context.Context,
client *signalmeow.Client,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
dm *signalpb.DataMessage,
) *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, true))
// Don't allow any other parts in a disappearing timer change message
return cm
}
if dm.GetExpireTimer() > 0 {
cm.Disappear.Type = database.DisappearingTypeAfterRead
cm.Disappear.Timer = time.Duration(dm.GetExpireTimer()) * time.Second
}
if dm.Sticker != nil {
cm.Parts = append(cm.Parts, mc.convertStickerToMatrix(ctx, dm.Sticker))
// Don't allow any other parts in a sticker message
return cm
}
for i, att := range dm.GetAttachments() {
if att.GetContentType() != "text/x-signal-plain" {
cm.Parts = append(cm.Parts, mc.convertAttachmentToMatrix(ctx, i, att))
} else {
longBody, err := mc.downloadSignalLongText(ctx, att)
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))
}
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))
}
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.",
},
})
}
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 := uuid.Parse(dm.Quote.GetAuthorAci())
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("author_aci", dm.Quote.GetAuthorAci()).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, updatePortal bool) *bridgev2.ConvertedMessagePart {
part := &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: bridgev2.DisappearingMessageNotice(time.Duration(timer)*time.Second, false),
}
if updatePortal {
portal := getPortal(ctx)
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
}
portal.Disappear.Timer = time.Duration(timer) * time.Second
if timer == 0 {
portal.Disappear.Type = ""
} else {
portal.Disappear.Type = database.DisappearingTypeAfterRead
}
if timerVersion != nil {
portalMeta.ExpirationTimerVersion = *timerVersion
} else {
portalMeta.ExpirationTimerVersion = 1
}
err := portal.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database")
}
}
return part
}
func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *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)
}
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) 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 := signalmeow.DownloadAttachment(ctx, contact.GetAvatar().GetAvatar())
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) *bridgev2.ConvertedMessagePart {
card := mc.convertContactToVCard(ctx, contact)
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) *bridgev2.ConvertedMessagePart {
part, err := mc.reuploadAttachment(ctx, att)
if err != nil {
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) *bridgev2.ConvertedMessagePart {
converted, err := mc.reuploadAttachment(ctx, sticker.GetData())
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 256x256
if converted.Content.Info.Width == 512 && converted.Content.Info.Height == 512 {
converted.Content.Info.Width = 256
converted.Content.Info.Height = 256
}
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) (*string, error) {
data, err := signalmeow.DownloadAttachment(ctx, att)
if err != nil {
return nil, fmt.Errorf("failed to download attachment: %w", err)
}
longBody := string(data)
return &longBody, nil
}
func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*bridgev2.ConvertedMessagePart, error) {
data, err := signalmeow.DownloadAttachment(ctx, att)
if err != nil {
return nil, fmt.Errorf("failed to download attachment: %w", err)
}
mimeType := att.GetContentType()
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
fileName := att.GetFileName()
content := &event.MessageEventContent{
Info: &event.FileInfo{
Width: int(att.GetWidth()),
Height: int(att.GetHeight()),
Size: len(data),
},
}
if att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 && ffmpeg.Supported() {
data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType)
if err != nil {
return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err)
}
fileName += ".ogg"
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{}
}
content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, fileName, mimeType)
if err != nil {
return nil, err
}
if att.GetBlurHash() != "" {
content.Info.Blurhash = att.GetBlurHash()
content.Info.AnoaBlurhash = att.GetBlurHash()
}
switch strings.Split(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
}
content.Body = fileName
content.Info.MimeType = mimeType
if content.Body == "" {
content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(mimeType)
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
}, nil
}