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

224 lines
7.5 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 (
"context"
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/variationselector"
"golang.org/x/exp/constraints"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
func (mc *MessageConverter) ToSignal(
ctx context.Context,
client *signalmeow.Client,
portal *bridgev2.Portal,
evt *event.Event,
content *event.MessageEventContent,
relaybotFormatted bool,
replyTo *database.Message,
) (*signalpb.DataMessage, error) {
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyPortal, portal)
if evt.Type == event.EventSticker {
content.MsgType = event.MessageType(event.EventSticker.Type)
}
// Matrix timestamps can be faked, but if the user is using their own Signal account, faking timestamps is their problem.
ts := uint64(evt.Timestamp)
// However, when relaying, timestamps shouldn't be trusted because anyone can send a message with any timestamp.
if relaybotFormatted {
ts = uint64(time.Now().UnixMilli())
}
dm := &signalpb.DataMessage{
Timestamp: &ts,
Preview: mc.convertURLPreviewToSignal(ctx, content),
}
if replyTo != nil {
authorACI, messageID, err := signalid.ParseMessageID(replyTo.ID)
if err == nil {
dm.Quote = &signalpb.DataMessage_Quote{
Id: proto.Uint64(messageID),
AuthorAci: proto.String(authorACI.String()),
Type: signalpb.DataMessage_Quote_NORMAL.Enum(),
}
if replyTo.Metadata.(*signalid.MessageMetadata).ContainsAttachments {
dm.Quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1)
}
}
}
if portal.Disappear.Timer > 0 {
dm.ExpireTimer = proto.Uint32(uint32(portal.Disappear.Timer.Seconds()))
timerVersion := portal.Metadata.(*signalid.PortalMetadata).ExpirationTimerVersion
if timerVersion > 0 {
dm.ExpireTimerVersion = &timerVersion
}
}
if content.MsgType == event.MsgEmote && !relaybotFormatted {
content.Body = "/me " + content.Body
if content.FormattedBody != "" {
content.FormattedBody = "/me " + content.FormattedBody
}
}
body, bodyRanges := matrixfmt.Parse(ctx, mc.MatrixFmtParams, content)
switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
dm.Body = proto.String(body)
dm.BodyRanges = bodyRanges
case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
att, err := mc.convertFileToSignal(ctx, evt, content)
if err != nil {
return nil, fmt.Errorf("failed to convert attachment: %w", err)
}
if content.FileName != "" && (content.FileName != content.Body || content.Format == event.FormatHTML) {
dm.Body = proto.String(body)
dm.BodyRanges = bodyRanges
}
dm.Attachments = []*signalpb.AttachmentPointer{att}
case event.MessageType(event.EventSticker.Type):
if content.FileName == "" {
content.FileName = "sticker" + exmime.ExtensionFromMimetype(content.Info.MimeType)
}
att, err := mc.convertFileToSignal(ctx, evt, content)
if err != nil {
return nil, fmt.Errorf("failed to convert sticker: %w", err)
}
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_BORDERLESS))
var emoji *string
// TODO check for single grapheme cluster?
if len([]rune(content.Body)) == 1 {
emoji = proto.String(variationselector.Remove(content.Body))
}
dm.Sticker = &signalpb.DataMessage_Sticker{
// Signal iOS validates that pack id/key are of the correct length.
// Android is fine with any non-nil values (like a zero-length byte string).
PackId: make([]byte, 16),
PackKey: make([]byte, 32),
StickerId: proto.Uint32(0),
Data: att,
Emoji: emoji,
}
case event.MsgLocation:
lat, lon, err := parseGeoURI(content.GeoURI)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Invalid geo URI")
return nil, err
}
locationString := fmt.Sprintf(mc.LocationFormat, lat, lon)
dm.Body = &locationString
default:
return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType)
}
return dm, nil
}
func maybeInt[T constraints.Integer](v T) *T {
if v == 0 {
return nil
}
return &v
}
func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*signalpb.AttachmentPointer, error) {
log := zerolog.Ctx(ctx)
data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
if err != nil {
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
}
fileName := content.Body
if content.FileName != "" {
fileName = content.FileName
}
mime := content.GetInfo().MimeType
if content.MSC3245Voice != nil && ffmpeg.Supported() {
data, err = ffmpeg.ConvertBytes(ctx, data, ".aac", []string{}, []string{"-c:a", "aac"}, mime)
if err != nil {
return nil, err
}
mime = "audio/aac"
fileName += ".aac"
} else if evt.Type == event.EventSticker {
switch mime {
case "image/webp", "image/png", "image/apng":
// allowed
case "image/gif":
if !ffmpeg.Supported() {
return nil, fmt.Errorf("converting gif stickers is not supported")
}
data, err = ffmpeg.ConvertBytes(ctx, data, ".apng", []string{}, []string{}, mime)
if err != nil {
return nil, fmt.Errorf("%w (gif to apng): %w", bridgev2.ErrMediaConvertFailed, err)
}
fileName += ".apng"
mime = "image/apng"
default:
return nil, fmt.Errorf("unsupported content type for sticker %s", mime)
}
}
att, err := getClient(ctx).UploadAttachment(ctx, data)
if err != nil {
log.Err(err).Msg("Failed to upload file")
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
}
if content.MSC3245Voice != nil && mime == "audio/aac" {
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_VOICE_MESSAGE))
}
att.ContentType = proto.String(mime)
att.FileName = &fileName
att.Height = maybeInt(uint32(content.Info.Height))
att.Width = maybeInt(uint32(content.Info.Width))
if content.Info.Blurhash != "" {
att.BlurHash = proto.String(content.Info.Blurhash)
} else if content.Info.AnoaBlurhash != "" {
att.BlurHash = proto.String(content.Info.AnoaBlurhash)
}
return att, nil
}
func parseGeoURI(uri string) (lat, long string, err error) {
if !strings.HasPrefix(uri, "geo:") {
err = fmt.Errorf("uri doesn't have geo: prefix")
return
}
// Remove geo: prefix and anything after ;
coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
splitCoordinates := strings.Split(coordinates, ",")
if len(splitCoordinates) != 2 {
err = fmt.Errorf("didn't find exactly two numbers separated by a comma")
} else {
lat = splitCoordinates[0]
long = splitCoordinates[1]
}
return
}