217 lines
7.2 KiB
Go
217 lines
7.2 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"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
"go.mau.fi/util/exerrors"
|
|
"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/event"
|
|
|
|
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
|
|
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
|
|
)
|
|
|
|
var (
|
|
ErrUnsupportedMsgType = errors.New("unsupported msgtype")
|
|
ErrMediaDownloadFailed = errors.New("failed to download media")
|
|
ErrMediaDecryptFailed = errors.New("failed to decrypt media")
|
|
ErrMediaConvertFailed = errors.New("failed to convert")
|
|
ErrMediaUploadFailed = errors.New("failed to upload media")
|
|
ErrInvalidGeoURI = errors.New("invalid `geo:` URI in message")
|
|
)
|
|
|
|
func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent, relaybotFormatted bool) (*signalpb.DataMessage, error) {
|
|
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,
|
|
Quote: mc.GetSignalReply(ctx, content),
|
|
Preview: mc.convertURLPreviewToSignal(ctx, evt),
|
|
}
|
|
if expirationTime := mc.GetData(ctx).ExpirationTime; expirationTime != 0 {
|
|
dm.ExpireTimer = proto.Uint32(uint32(expirationTime))
|
|
}
|
|
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 {
|
|
log.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", ErrUnsupportedMsgType, 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)
|
|
mxc := content.URL
|
|
if content.File != nil {
|
|
mxc = content.File.URL
|
|
}
|
|
data, err := mc.DownloadMatrixMedia(ctx, mxc)
|
|
if err != nil {
|
|
return nil, exerrors.NewDualError(ErrMediaDownloadFailed, err)
|
|
}
|
|
if content.File != nil {
|
|
err = content.File.DecryptInPlace(data)
|
|
if err != nil {
|
|
return nil, exerrors.NewDualError(ErrMediaDecryptFailed, err)
|
|
}
|
|
}
|
|
fileName := content.Body
|
|
if content.FileName != "" {
|
|
fileName = content.FileName
|
|
}
|
|
_, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"]
|
|
mime := content.GetInfo().MimeType
|
|
if isVoice {
|
|
data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mime = "audio/aac"
|
|
fileName += ".m4a"
|
|
} else if evt.Type == event.EventSticker && mime != "image/webp" && mime != "image/png" && mime != "image/apng" {
|
|
switch mime {
|
|
case "image/webp", "image/png", "image/apng":
|
|
// allowed
|
|
case "image/gif":
|
|
if !mc.ConvertGIFToAPNG {
|
|
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", ErrMediaConvertFailed, err)
|
|
}
|
|
fileName += ".apng"
|
|
mime = "image/apng"
|
|
default:
|
|
return nil, fmt.Errorf("unsupported content type for sticker %s", mime)
|
|
}
|
|
}
|
|
att, err := mc.GetClient(ctx).UploadAttachment(ctx, data)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to upload file")
|
|
return nil, exerrors.NewDualError(ErrMediaUploadFailed, err)
|
|
}
|
|
if isVoice {
|
|
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
|
|
}
|