// 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 ( "context" "fmt" "net/http" "strings" "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) } dm := &signalpb.DataMessage{ 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), AuthorAciBinary: authorACI[:], Type: signalpb.DataMessage_Quote_NORMAL.Enum(), } if replyTo.Metadata.(*signalid.MessageMetadata).ContainsAttachments { dm.Quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1) } } } if content.BeeperDisappearingTimer != nil { dm.ExpireTimer = proto.Uint32(uint32(content.BeeperDisappearingTimer.Timer.Seconds())) } else if portal.Disappear.Timer > 0 { dm.ExpireTimer = proto.Uint32(uint32(portal.Disappear.Timer.Seconds())) } if dm.ExpireTimer != nil && *dm.ExpireTimer != 0 { 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 mime == "" { mime = http.DetectContentType(data) } if content.MSC3245Voice != nil && mime != "audio/aac" && 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)) } if content.Info.MauGIF { att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_GIF)) } 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 }