224 lines
7.5 KiB
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
|
|
}
|