478 lines
15 KiB
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
|
|
}
|