Files
mirrors-mautrix-telegram/pkg/connector/tomatrix.go

1124 lines
35 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2025 Sumner Evans
//
// 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 connector
import (
"cmp"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
"go.mau.fi/mautrix-telegram/pkg/connector/media"
"go.mau.fi/mautrix-telegram/pkg/connector/store"
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
"go.mau.fi/mautrix-telegram/pkg/connector/waveform"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
)
type spoilable interface {
GetSpoiler() bool
}
type ttlable interface {
GetTTLSeconds() (value int, ok bool)
}
func mediaHashID(ctx context.Context, m tg.MessageMediaClass) []byte {
if m == nil {
return nil
}
switch media := m.(type) {
case *tg.MessageMediaPhoto:
if media.Video != nil {
return binary.BigEndian.AppendUint64(nil, uint64(media.Video.GetID()))
} else if media.Photo != nil {
return binary.BigEndian.AppendUint64(nil, uint64(media.Photo.GetID()))
} else {
zerolog.Ctx(ctx).Debug().Msg("Attempted to get hash for nil photo")
}
case *tg.MessageMediaDocument:
if media.Document != nil {
return binary.BigEndian.AppendUint64(nil, uint64(media.Document.GetID()))
} else {
zerolog.Ctx(ctx).Debug().Msg("Attempted to get hash for nil document")
}
default:
zerolog.Ctx(ctx).Debug().Type("media_type", m).Msg("Attempted to get hash for unsupported media type ID")
}
return nil
}
func (tc *TelegramClient) mediaToMatrix(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
msg *tg.Message,
) (*bridgev2.ConvertedMessagePart, *database.DisappearingSetting, []byte) {
media, ok := msg.GetMedia()
if !ok {
return nil, nil, nil
}
switch media.TypeID() {
case tg.MessageMediaWebPageTypeID:
// Already handled in the message handling
return nil, nil, nil
case tg.MessageMediaUnsupportedTypeID:
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "This message is not supported on your version of Mautrix-Telegram. Please check https://github.com/mautrix/telegram or ask your bridge administrator about possible updates.",
},
Extra: map[string]any{
"fi.mau.telegram.unsupported": true,
},
}, nil, nil
case tg.MessageMediaPhotoTypeID, tg.MessageMediaDocumentTypeID:
converted, disappearingSetting := tc.convertMediaRequiringUpload(ctx, portal, intent, msg.ID, media, true)
return converted, disappearingSetting, mediaHashID(ctx, media)
case tg.MessageMediaContactTypeID:
return tc.convertContact(media), nil, nil
case tg.MessageMediaGeoTypeID, tg.MessageMediaGeoLiveTypeID, tg.MessageMediaVenueTypeID:
return convertLocation(media), nil, nil
case tg.MessageMediaPollTypeID:
return convertPoll(media), nil, nil
case tg.MessageMediaDiceTypeID:
return convertDice(media), nil, nil
case tg.MessageMediaGameTypeID:
return convertGame(media), nil, nil
default:
// TODO: support these properly
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("%s are not yet supported. Open Telegram to view.", media.TypeName()),
},
Extra: map[string]any{
"fi.mau.telegram.unsupported": true,
"fi.mau.telegram.type_id": media.TypeID(),
},
}, nil, nil
}
}
func hasGuestchatViaFrom(msg *tg.Message) bool {
_, ok := msg.GetGuestchatViaFrom()
return ok
}
func (tc *TelegramClient) convertToMatrix(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
msg *tg.Message,
) (cm *bridgev2.ConvertedMessage, err error) {
log := zerolog.Ctx(ctx).With().Str("conversion_direction", "to_matrix").Logger()
ctx = log.WithContext(ctx)
if tc.client == nil {
return nil, fmt.Errorf("telegram client is nil, we are likely logged out")
}
var perMessageProfile *event.BeeperPerMessageProfile
if peerType, _, _, err := ids.ParsePortalID(portal.ID); err != nil {
return nil, fmt.Errorf("failed to parse portal ID: %w", err)
} else if (peerType == ids.PeerTypeChannel && !portal.Metadata.(*PortalMetadata).IsSuperGroup) || hasGuestchatViaFrom(msg) {
var sender *networkid.UserID
if msg.Out {
sender = &tc.userID
} else if fromID, ok := msg.GetFromID(); ok {
sender = ptr.Ptr(tc.getPeerSender(fromID).Sender)
}
if sender != nil {
ghost, err := portal.Bridge.GetGhostByID(ctx, *sender)
if err != nil {
return nil, fmt.Errorf("failed to get ghost for per message profile: %w", err)
}
perMessageProfile = &event.BeeperPerMessageProfile{
ID: string(ghost.ID),
Displayname: ghost.Name,
AvatarURL: &ghost.AvatarMXC,
}
}
}
cm = &bridgev2.ConvertedMessage{}
hasher := sha256.New()
if len(msg.Message) > 0 {
hasher.Write([]byte(msg.Message))
content := tc.parseBodyAndHTML(ctx, msg.Message, msg.Entities)
if media, ok := msg.GetMedia(); ok && media.TypeID() == tg.MessageMediaWebPageTypeID {
webpageCtx, webpageCtxCancel := context.WithTimeout(ctx, time.Second*5)
defer webpageCtxCancel()
preview, err := tc.webpageToBeeperLinkPreview(webpageCtx, portal, intent, msg, media)
if err != nil {
log.Err(err).Msg("Failed to convert webpage to link preview")
} else if preview != nil {
hasher.Write([]byte(preview.MatchedURL))
content.BeeperLinkPreviews = append(content.BeeperLinkPreviews, preview)
}
}
cm.Parts = []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: content,
}}
}
var contentURI id.ContentURIString
mediaPart, disappearingSetting, mediaHashID := tc.mediaToMatrix(ctx, portal, intent, msg)
if mediaPart != nil {
hasher.Write(mediaHashID)
cm.Parts = append(cm.Parts, mediaPart)
// Force stickers into images if there is a caption (usually there shouldn't be)
if mediaPart.Type == event.EventSticker && len(cm.Parts) == 2 {
mediaPart.Type = event.EventMessage
mediaPart.Content.MsgType = event.MsgImage
}
cm.MergeCaption()
contentURI = mediaPart.Content.URL
if contentURI == "" && mediaPart.Content.File != nil {
contentURI = mediaPart.Content.File.URL
}
if disappearingSetting != nil {
cm.Disappear = *disappearingSetting
}
}
if perMessageProfile != nil {
cm.Parts[0].Content.BeeperPerMessageProfile = perMessageProfile
cm.Parts[0].Content.AddPerMessageProfileFallback()
}
cm.Parts[0].DBMetadata = &MessageMetadata{
ContentHash: hasher.Sum(nil),
ContentURI: contentURI,
}
if fwd, isForwarded := msg.GetFwdFrom(); isForwarded {
err = tc.addForwardHeader(ctx, cm.Parts[0], fwd)
if err != nil {
return nil, fmt.Errorf("failed to add forward header: %w", err)
}
}
if replyTo, ok := msg.GetReplyTo(); ok {
switch replyTo := replyTo.(type) {
case *tg.MessageReplyHeader:
if (replyTo.ReplyToTopID != 0 || !replyTo.ForumTopic) && replyTo.ReplyToTopID != replyTo.ReplyToMsgID {
cm.ReplyTo = &networkid.MessageOptionalPartID{}
if peerID, present := replyTo.GetReplyToPeerID(); present {
cm.ReplyTo.MessageID = ids.MakeMessageID(peerID, replyTo.ReplyToMsgID)
} else {
cm.ReplyTo.MessageID = ids.MakeMessageID(portal.PortalKey, replyTo.ReplyToMsgID)
}
}
if replyTo.Quote {
parsedQuote := tc.parseBodyAndHTML(ctx, replyTo.QuoteText, replyTo.QuoteEntities)
parsedQuote.EnsureHasHTML()
existingPart := cm.Parts[0]
existingPart.Content.EnsureHasHTML()
existingPart.Content.FormattedBody = fmt.Sprintf(
"<blockquote data-telegram-partial-reply>%s</blockquote>%s",
parsedQuote.FormattedBody,
existingPart.Content.FormattedBody,
)
}
default:
log.Warn().Type("reply_to", replyTo).Msg("unhandled reply to type")
}
}
if cm.Parts[0].Extra == nil {
cm.Parts[0].Extra = make(map[string]any)
}
if externalURL := getMessageLink(msg); externalURL != "" {
cm.Parts[0].Extra["external_url"] = externalURL
}
if len(cm.Parts) > 1 {
log.Warn().Int("part_count", len(cm.Parts)).Msg("Message has multiple parts")
for i, part := range cm.Parts[1:] {
part.ID = networkid.PartID(strconv.Itoa(i + 1))
}
}
if ttl, ok := msg.GetTTLPeriod(); ok && disappearingSetting == nil {
cm.Disappear = database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend,
Timer: time.Duration(ttl) * time.Second,
}
}
return
}
func getMessageLink(msg *tg.Message) string {
var chatID int64
switch peer := msg.PeerID.(type) {
case *tg.PeerChat:
chatID = peer.ChatID
case *tg.PeerChannel:
chatID = peer.ChannelID
default: // also PeerUser
return ""
}
topicID := rawGetTopicID(msg.ReplyTo)
if topicID > 0 {
return fmt.Sprintf("https://t.me/c/%d/%d/%d", chatID, topicID, msg.ID)
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msg.ID)
}
func (tc *TelegramClient) addForwardHeader(ctx context.Context, part *bridgev2.ConvertedMessagePart, fwd tg.MessageFwdHeader) error {
var fwdFromText, fwdFromHTML string
switch from := fwd.FromID.(type) {
case *tg.PeerUser:
user := tc.main.Bridge.GetCachedUserLoginByID(ids.MakeUserLoginID(from.UserID))
var mxid id.UserID
if user != nil {
mxid = user.UserMXID
fwdFromText = cmp.Or(user.RemoteName, user.UserMXID.String())
} else if ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(from.UserID)); err != nil {
return err
} else {
if ghost.Name == "" {
info, err := tc.GetUserInfo(ctx, ghost)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get user info to add forward header")
} else if info != nil {
ghost.UpdateInfo(ctx, info)
}
}
mxid = ghost.Intent.GetMXID()
fwdFromText = cmp.Or(ghost.Name, fwd.FromName, "unknown user")
}
fwdFromHTML = fmt.Sprintf(
`<a href="%s">%s</a>`,
mxid.URI().MatrixToURL(),
html.EscapeString(fwdFromText),
)
case *tg.PeerChannel, *tg.PeerChat:
unknownType := "unknown chat"
var channelID int64
if ch, ok := from.(*tg.PeerChannel); ok {
unknownType = "unknown channel"
channelID = ch.ChannelID
}
portal, err := tc.main.Bridge.GetExistingPortalByKey(ctx, tc.makePortalKeyFromPeer(from, 0))
if err != nil {
return err
} else if portal != nil && portal.MXID != "" {
fwdFromText = cmp.Or(portal.Name, fwd.FromName, unknownType)
fwdFromHTML = fmt.Sprintf(
`<a href="%s">%s</a>`,
portal.MXID.URI().MatrixToURL(),
html.EscapeString(fwdFromText),
)
} else if fwd.FromName != "" {
fwdFromText = fwd.FromName
fwdFromHTML = fmt.Sprintf("<strong>%s</strong>", html.EscapeString(fwd.FromName))
} else {
fwdFromText = unknownType
fwdFromHTML = unknownType
}
if channelID != 0 && fwdFromText == unknownType {
ghost, err := tc.main.Bridge.GetExistingGhostByID(ctx, ids.MakeChannelUserID(channelID))
if err != nil {
return err
} else if ghost != nil && ghost.Name != "" {
fwdFromText = ghost.Name
fwdFromHTML = fmt.Sprintf(
`<a href="%s">%s</a>`,
ghost.Intent.GetMXID().URI().MatrixToURL(),
html.EscapeString(fwdFromText),
)
}
}
// TODO fetch channel if not found
}
if fwdFromText == "" && fwd.FromName != "" {
fwdFromText = fwd.FromName
fwdFromHTML = fmt.Sprintf("<strong>%s</strong>", html.EscapeString(fwd.FromName))
}
if fwdFromText == "" {
fwdFromText = "unknown source"
fwdFromHTML = fwdFromText
}
if part.Content.MsgType.IsMedia() {
if part.Content.FileName == "" {
part.Content.FileName = part.Content.Body
}
if part.Content.Body == part.Content.FileName {
part.Content.Body = ""
}
}
part.Content.EnsureHasHTML()
existingBodyLines := strings.Split(part.Content.Body, "\n")
for i, line := range existingBodyLines {
existingBodyLines[i] = fmt.Sprintf("> %s", line)
}
if len(existingBodyLines) > 0 {
existingBodyLines = append([]string{"\n"}, existingBodyLines...)
}
part.Content.Body = fmt.Sprintf(
"Forwarded message from %s%s",
fwdFromText, strings.Join(existingBodyLines, "\n"),
)
existingFormattedBody := part.Content.FormattedBody
if existingFormattedBody != "" {
existingFormattedBody = fmt.Sprintf("<br><blockquote data-telegram-forward>%s</blockquote>", existingFormattedBody)
}
part.Content.FormattedBody = fmt.Sprintf(
"Forwarded message from %s%s",
fwdFromHTML, existingFormattedBody,
)
return nil
}
func (tc *TelegramClient) parseBodyAndHTML(ctx context.Context, message string, entities []tg.MessageEntityClass) *event.MessageEventContent {
if len(entities) == 0 {
return &event.MessageEventContent{MsgType: event.MsgText, Body: message}
}
var customEmojiIDs []int64
for _, entity := range entities {
switch entity := entity.(type) {
case *tg.MessageEntityCustomEmoji:
customEmojiIDs = append(customEmojiIDs, entity.DocumentID)
}
}
customEmojis, err := tc.transferEmojisToMatrix(ctx, customEmojiIDs)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Ints64("emoji_ids", customEmojiIDs).
Msg("Failed to transfer custom emojis to Matrix")
}
return telegramfmt.Parse(ctx, message, entities, tc.telegramFmtParams.WithCustomEmojis(customEmojis))
}
func (tc *TelegramClient) webpageToBeeperLinkPreview(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message, msgMedia tg.MessageMediaClass) (preview *event.BeeperLinkPreview, err error) {
webpage, ok := msgMedia.(*tg.MessageMediaWebPage).Webpage.(*tg.WebPage)
if !ok {
return nil, nil
}
preview = &event.BeeperLinkPreview{
MatchedURL: webpage.URL,
LinkPreview: event.LinkPreview{
Title: webpage.Title,
CanonicalURL: webpage.URL,
Description: webpage.Description,
},
}
if photo, ok := webpage.Photo.(*tg.Photo); ok {
var fileInfo *event.FileInfo
transferer := media.NewTransferer(tc.client.API()).WithPhoto(photo)
if tc.main.useDirectMedia {
preview.ImageURL, fileInfo, err = transferer.DirectDownloadURL(ctx, tc.telegramUserID, portal, msg.ID, true, 0)
} else {
preview.ImageURL, preview.ImageEncryption, fileInfo, err = transferer.Transfer(ctx, tc.main.Store, intent)
}
if err != nil {
return nil, err
}
preview.ImageSize = event.IntOrString(fileInfo.Size)
preview.ImageWidth = event.IntOrString(fileInfo.Width)
preview.ImageHeight = event.IntOrString(fileInfo.Height)
preview.ImageType = fileInfo.MimeType
if fileInfo.MimeType == "application/octet-stream" {
preview.ImageType = "image/jpeg"
}
}
return preview, nil
}
func (tc *TelegramClient) convertMediaRequiringUpload(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
msgID int,
msgMedia tg.MessageMediaClass,
allowRefetch bool,
) (converted *bridgev2.ConvertedMessagePart, disappearingSetting *database.DisappearingSetting) {
log := zerolog.Ctx(ctx).With().
Str("conversion_direction", "to_matrix").
Str("portal_id", string(portal.ID)).
Int("msg_id", msgID).
Logger()
eventType := event.EventMessage
var content event.MessageEventContent
var telegramMediaID int64
var isSticker, isVideo, isVideoGif bool
extra := map[string]any{}
// FIXME don't use raw map for fields in the FileInfo struct
extraInfo := map[string]any{}
transferer := media.NewTransferer(tc.client.API()).WithRoomID(portal.MXID)
var mediaTransferer *media.ReadyTransferer
if t, ok := msgMedia.(ttlable); ok {
if ttl, ok := t.GetTTLSeconds(); ok {
typeName := "photo"
if msgMedia.TypeID() == tg.MessageMediaDocumentTypeID {
typeName = "file"
}
if ttl == 2147483647 {
// This is a view-once message, set a low TTL.
ttl = 15
if tc.main.Config.DisableViewOnce {
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("You received a view once %s. For added privacy, you can only open it on the Telegram app.", typeName),
},
}
return
}
}
disappearingSetting = &database.DisappearingSetting{
// Even though normal message TTLs are after send, media is after read
Type: event.DisappearingTypeAfterRead,
Timer: time.Duration(ttl) * time.Second,
}
}
}
// Determine the filename and some other information
switch msgMedia := msgMedia.(type) {
case *tg.MessageMediaPhoto:
content.MsgType = event.MsgImage
if disappearingSetting != nil {
content.Body = "disappearing_image"
} else {
content.Body = "image"
}
photo, ok := msgMedia.Photo.(*tg.Photo)
if !ok {
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported photo message. Check Telegram app.",
},
}
return
}
if video, ok := msgMedia.Video.(*tg.Document); ok {
content.MsgType = event.MsgVideo
content.Body = strings.Replace(content.Body, "image", "live_photo", 1)
telegramMediaID = video.GetID()
mediaTransferer = transferer.WithLivePhoto(photo, video)
extraInfo["fi.mau.telegram.live_photo"] = true
// TODO deduplicate with document thumbnail code
var thumbnailURL id.ContentURIString
var thumbnailFile *event.EncryptedFileInfo
var thumbnailInfo *event.FileInfo
var err error
thumbnailTransferer := media.NewTransferer(tc.client.API()).
WithRoomID(portal.MXID).
WithPhoto(photo)
if tc.main.useDirectMedia {
thumbnailURL, thumbnailInfo, err = thumbnailTransferer.DirectDownloadURL(ctx, tc.telegramUserID, portal, msgID, false, photo.ID)
if err != nil {
log.Err(err).Msg("Failed to create direct download URL for thumbnail")
}
}
if thumbnailURL == "" {
thumbnailURL, thumbnailFile, thumbnailInfo, err = thumbnailTransferer.Transfer(ctx, tc.main.Store, intent)
if err != nil {
log.Err(err).Msg("Failed to transfer thumbnail")
}
}
if thumbnailURL != "" || thumbnailFile != nil {
transferer = transferer.WithThumbnail(thumbnailURL, thumbnailFile, thumbnailInfo)
}
} else {
telegramMediaID = photo.GetID()
mediaTransferer = transferer.WithPhoto(photo)
}
case *tg.MessageMediaDocument:
document, ok := msgMedia.Document.(*tg.Document)
if !ok {
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported document message. Check Telegram app.",
},
}
return
}
telegramMediaID = document.GetID()
content.MsgType = event.MsgFile
defaultFileName := "file_document"
for _, attr := range document.GetAttributes() {
switch a := attr.(type) {
case *tg.DocumentAttributeFilename:
if content.Body == "" {
content.Body = a.GetFileName()
} else {
content.FileName = a.GetFileName()
}
case *tg.DocumentAttributeVideo:
defaultFileName = "video_document"
isVideo = true
content.MsgType = event.MsgVideo
transferer = transferer.WithVideo(a)
if a.RoundMessage {
extraInfo["fi.mau.telegram.round_message"] = a.RoundMessage
}
case *tg.DocumentAttributeAudio:
if content.MsgType != event.MsgVideo {
content.MsgType = event.MsgAudio
transferer = transferer.WithAudio(a) // only set the duration is not already set by the video handling logic
}
content.MSC1767Audio = &event.MSC1767Audio{
Duration: a.Duration * 1000,
}
if wf, ok := a.GetWaveform(); ok {
for _, v := range waveform.Decode(wf) {
content.MSC1767Audio.Waveform = append(content.MSC1767Audio.Waveform, int(v)<<5)
}
}
if a.Voice {
defaultFileName = "Voice message"
content.MSC3245Voice = &event.MSC3245Voice{}
} else {
defaultFileName = "audio_document"
}
case *tg.DocumentAttributeImageSize:
transferer = transferer.WithImageSize(a)
if content.MsgType == event.MsgFile && !isSticker {
content.MsgType = event.MsgImage
extra["fi.mau.telegram.force_document"] = true
defaultFileName = "image_document"
}
case *tg.DocumentAttributeSticker:
isSticker = true
if content.Body == "" {
content.Body = a.Alt
} else {
content.FileName = content.Body
content.Body = a.Alt
}
transferer = transferer.
WithStickerConfig(tc.main.Config.AnimatedSticker).
WithStickerMetadata(tc.stickerSourceFromAttribute(ctx, document.ID, a))
case *tg.DocumentAttributeAnimated:
isVideoGif = true
extraInfo["fi.mau.telegram.gif"] = true
defaultFileName = "gif"
}
}
if content.FileName == "" && content.Body == "" {
if content.Body != "" {
content.FileName = content.Body
} else {
content.Body = defaultFileName
}
}
if isSticker {
// Strip filename so that we never render the caption
content.FileName = ""
if tc.main.Config.AnimatedSticker.Target == "webm" || (isVideo && !tc.main.Config.AnimatedSticker.ConvertFromWebm) {
isVideoGif = true
extraInfo["fi.mau.telegram.animated_sticker"] = true
transferer.WithMIMEType("video/webm")
} else {
eventType = event.EventSticker
content.MsgType = "" // Strip the msgtype since that doesn't apply for stickers
}
}
if _, ok := document.GetThumbs(); ok && eventType != event.EventSticker {
var thumbnailURL id.ContentURIString
var thumbnailFile *event.EncryptedFileInfo
var thumbnailInfo *event.FileInfo
var err error
thumbnailTransferer := media.NewTransferer(tc.client.API()).
WithRoomID(portal.MXID).
WithDocument(document, true)
if tc.main.useDirectMedia {
thumbnailURL, thumbnailInfo, err = thumbnailTransferer.DirectDownloadURL(ctx, tc.telegramUserID, portal, msgID, true, document.ID)
if err != nil {
log.Err(err).Msg("Failed to create direct download URL for thumbnail")
}
}
if thumbnailURL == "" {
thumbnailURL, thumbnailFile, thumbnailInfo, err = thumbnailTransferer.Transfer(ctx, tc.main.Store, intent)
if err != nil {
log.Err(err).Msg("Failed to transfer thumbnail")
}
}
if thumbnailURL != "" || thumbnailFile != nil {
transferer = transferer.WithThumbnail(thumbnailURL, thumbnailFile, thumbnailInfo)
}
}
mediaTransferer = transferer.
WithFilename(content.Body).
WithDocument(msgMedia.Document, false)
default:
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported media message. Check Telegram app.",
},
}
return
}
var err error
if tc.main.useDirectMedia {
content.URL, content.Info, err = mediaTransferer.DirectDownloadURL(ctx, tc.telegramUserID, portal, msgID, false, telegramMediaID)
if err != nil {
log.Err(err).Msg("Failed to create direct download URL for media")
}
}
if content.URL == "" {
content.URL, content.File, content.Info, err = mediaTransferer.Transfer(ctx, tc.main.Store, intent)
if err != nil {
if tgerr.Is(err, tg.ErrFileReferenceExpired) && allowRefetch {
log.Warn().Err(err).Msg("Failed to transfer media, trying to refetch from message")
peerType, peerID, _, err := ids.ParsePortalID(portal.ID)
if err != nil {
log.Err(err).Msg("Failed to parse portal ID to refetch media")
} else if msgMedia, err = tc.refetchMedia(ctx, peerType, peerID, msgID); err != nil {
log.Err(err).Msg("Failed to refetch media after file reference expired error")
} else {
return tc.convertMediaRequiringUpload(ctx, portal, intent, msgID, msgMedia, false)
}
} else {
log.Err(err).Msg("Failed to transfer media")
}
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Failed to transfer media. Check Telegram app.",
},
}
return
}
}
if eventType != event.EventSticker && content.MsgType.IsMedia() {
if (content.FileName == "" || content.FileName == content.Body) && !strings.ContainsRune(content.Body, '.') {
content.Body = content.Body + exmime.ExtensionFromMimetype(content.Info.MimeType)
content.FileName = content.Body
} else if content.FileName != content.Body && content.FileName != "" && !strings.ContainsRune(content.FileName, '.') {
content.FileName = content.FileName + exmime.ExtensionFromMimetype(content.Info.MimeType)
}
}
if isVideoGif {
content.Info.MauGIF = true
extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true
extraInfo["fi.mau.hide_controls"] = true
extraInfo["fi.mau.no_audio"] = true
}
// Handle spoilers
// See: https://github.com/matrix-org/matrix-spec-proposals/pull/3725
if s, ok := msgMedia.(spoilable); ok && s.GetSpoiler() {
extra["town.robin.msc3725.content_warning"] = map[string]any{
"type": "town.robin.msc3725.spoiler",
}
extra["page.codeberg.everypizza.msc4193.spoiler"] = true
extraInfo["fi.mau.telegram.spoiler"] = true
}
if len(extraInfo) > 0 {
content.Info.Extra = extraInfo
}
converted = &bridgev2.ConvertedMessagePart{
Type: eventType,
Content: &content,
Extra: extra,
}
return
}
func (tc *TelegramClient) convertContact(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
contact := media.(*tg.MessageMediaContact)
name := tc.main.Config.FormatDisplayname(contact.FirstName, contact.LastName, "", false, contact.UserID)
formattedPhone := fmt.Sprintf("+%s", strings.TrimPrefix(contact.PhoneNumber, "+"))
content := event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Shared contact info for %s: %s", name, formattedPhone),
}
if contact.UserID > 0 {
content.Format = event.FormatHTML
content.FormattedBody = fmt.Sprintf(
`Shared contact info for <a href="%s">%s</a>: %s`,
tc.main.Bridge.Matrix.GhostIntent(ids.MakeUserID(contact.UserID)).GetMXID().URI().MatrixToURL(),
html.EscapeString(name),
html.EscapeString(formattedPhone),
)
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &content,
Extra: map[string]any{
"fi.mau.telegram.contact": map[string]any{
"user_id": contact.UserID,
"first_name": contact.FirstName,
"last_name": contact.LastName,
"phone_number": contact.PhoneNumber,
"vcard": contact.Vcard,
},
},
}
}
type hasGeo interface {
GetGeo() tg.GeoPointClass
}
func convertLocation(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
g, ok := media.(hasGeo)
if !ok || g.GetGeo().TypeID() != tg.GeoPointTypeID {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported location message. Check Telegram app.",
},
}
}
point := g.GetGeo().(*tg.GeoPoint)
var longChar, latChar string
if point.Long > 0 {
longChar = "E"
} else {
longChar = "W"
}
if point.Lat > 0 {
latChar = "N"
} else {
latChar = "S"
}
geo := fmt.Sprintf("%f,%f", point.Lat, point.Long)
geoURI := GeoURIFromLatLong(point.Lat, point.Long).URI()
body := fmt.Sprintf("%.4f° %s, %.4f° %s", point.Lat, latChar, point.Long, longChar)
url := fmt.Sprintf("https://maps.google.com/?q=%s", geo)
extra := map[string]any{}
var note string
if media.TypeID() == tg.MessageMediaGeoLiveTypeID {
note = "Live Location (see your Telegram client for live updates)"
} else if venue, ok := media.(*tg.MessageMediaVenue); ok {
note = venue.Title
body = fmt.Sprintf("%s (%s)", venue.Address, body)
extra["fi.mau.telegram.venue_id"] = venue.VenueID
} else {
note = "Location"
}
extra["org.matrix.msc3488.location"] = map[string]any{
"uri": geoURI,
"description": note,
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgLocation,
GeoURI: geoURI,
Body: fmt.Sprintf("%s: %s\n%s", note, body, url),
Format: event.FormatHTML,
FormattedBody: fmt.Sprintf(`%s: <a href="%s">%s</a>`, note, url, body),
},
Extra: extra,
}
}
func convertPoll(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
// TODO (PLAT-25224) make this richer in the future once megabridge has support for polls
poll := media.(*tg.MessageMediaPoll)
var textAnswers []string
var htmlAnswers strings.Builder
for i, opt := range poll.Poll.Answers {
text := opt.GetText()
textAnswers = append(textAnswers, fmt.Sprintf("%d. %s", i+1, text.Text))
htmlAnswers.WriteString(fmt.Sprintf("<li>%s</li>", text.Text))
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Poll: %s\n%s\nOpen the Telegram app to vote.", poll.Poll.Question.Text, strings.Join(textAnswers, "\n")),
Format: event.FormatHTML,
FormattedBody: fmt.Sprintf(`<strong>Poll</strong>: %s<ol>%s</ol>Open the Telegram app to vote.`, poll.Poll.Question.Text, htmlAnswers.String()),
},
}
}
func convertDice(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
roll := media.(*tg.MessageMediaDice)
var result string
var text strings.Builder
text.WriteString(roll.Emoticon)
switch roll.Emoticon {
case "🎯":
text.WriteString(" Dart throw")
case "🎲":
text.WriteString(" Dice roll")
case "🏀":
text.WriteString(" Basketball throw")
case "🎰":
text.WriteString(" Slot machine")
emojis := map[int]string{
0: "🍫",
1: "🍒",
2: "🍋",
3: "7⃣",
}
res := roll.Value - 1
result = fmt.Sprintf("%s %s %s", emojis[res%4], emojis[res/4%4], emojis[res/16])
case "🎳":
text.WriteString(" Bowling")
result = map[int]string{
1: "miss",
2: "1 pin down",
3: "3 pins down, split",
4: "4 pins down, split",
5: "5 pins down",
6: "strike 🎉",
}[roll.Value]
case "⚽":
text.WriteString(" Football kick")
result = map[int]string{
1: "miss",
2: "hit the woodwork",
3: "goal", // seems to go in through the center
4: "goal",
5: "goal 🎉", // seems to go in through the top right corner, includes confetti
}[roll.Value]
}
text.WriteString(" result: ")
if len(result) > 0 {
text.WriteString(result)
text.WriteString(fmt.Sprintf(" (%d)", roll.Value))
} else {
text.WriteString(fmt.Sprintf("%d", roll.Value))
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: text.String(),
Format: event.FormatHTML,
FormattedBody: fmt.Sprintf("<h4>%s</h4>", text.String()),
},
Extra: map[string]any{
"fi.mau.telegram.dice": map[string]any{
"emoticon": roll.Emoticon,
"value": roll.Value,
},
},
}
}
func convertGame(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
// TODO (PLAT-25562) provide a richer experience for the game
game := media.(*tg.MessageMediaGame)
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Game: %s. Open the Telegram app to play.", game.Game.Title),
},
}
}
func (tc *TelegramClient) convertUserProfilePhoto(ctx context.Context, user *tg.User, photo *tg.UserProfilePhoto) (*bridgev2.Avatar, error) {
avatar := &bridgev2.Avatar{
ID: ids.MakeAvatarID(photo.PhotoID),
}
if tc.main.useDirectMedia {
mediaID, err := ids.DirectMediaInfo{
PeerType: ids.PeerTypeUser,
PeerID: user.ID,
UserID: tc.telegramUserID,
ID: photo.PhotoID,
}.AsMediaID()
if err != nil {
return nil, err
}
if avatar.MXC, err = tc.main.Bridge.Matrix.GenerateContentURI(ctx, mediaID); err != nil {
return nil, err
}
avatar.Hash = ids.HashMediaID(mediaID)
} else {
avatar.Get = func(ctx context.Context) (data []byte, err error) {
// TODO determine if it's safe to unconditionally use the access hash from the user object here
peer, err := tc.getInputPeerUser(ctx, user.ID)
if errors.Is(err, store.ErrNoAccessHash) {
peer = &tg.InputPeerUser{
UserID: user.ID,
AccessHash: user.AccessHash,
}
if user.Min && tc.metadata.IsBot {
// Bots should use a zero access hash when only a min hash is available
peer.AccessHash = 0
}
} else if err != nil {
return nil, fmt.Errorf("failed to get peer: %w", err)
}
return media.NewTransferer(tc.client.API()).WithPeerPhoto(peer, photo.PhotoID).DownloadBytes(ctx)
}
}
return avatar, nil
}
func (tc *TelegramClient) convertChatPhoto(chat tg.InputPeerClass, rawChatPhoto tg.ChatPhotoClass) (*bridgev2.Avatar, error) {
var chatPhoto *tg.ChatPhoto
switch typedChatPhoto := rawChatPhoto.(type) {
case *tg.ChatPhotoEmpty:
return &bridgev2.Avatar{Remove: true}, nil
case *tg.ChatPhoto:
chatPhoto = typedChatPhoto
default:
return nil, fmt.Errorf("not a chat photo: %T", rawChatPhoto)
}
avatar := &bridgev2.Avatar{
ID: ids.MakeAvatarID(chatPhoto.PhotoID),
}
if tc.main.useDirectMedia {
var peerID int64
var peerType ids.PeerType
switch typedChat := chat.(type) {
case *tg.InputPeerChannel:
peerID = typedChat.ChannelID
peerType = ids.PeerTypeChannel
case *tg.InputPeerChat:
peerID = typedChat.ChatID
peerType = ids.PeerTypeChat
case *tg.InputPeerUser:
peerID = typedChat.UserID
peerType = ids.PeerTypeUser
default:
return nil, fmt.Errorf("unsupported chat type for chat photo: %T", chat)
}
mediaID, err := ids.DirectMediaInfo{
PeerType: peerType,
PeerID: peerID,
UserID: tc.telegramUserID,
ID: chatPhoto.PhotoID,
}.AsMediaID()
if err != nil {
return nil, err
}
todoRemove := tc.main.Bridge.BackgroundCtx // TODO remove context parameter from GenerateContentURI
if avatar.MXC, err = tc.main.Bridge.Matrix.GenerateContentURI(todoRemove, mediaID); err != nil {
return nil, err
}
avatar.Hash = ids.HashMediaID(mediaID)
} else {
avatar.Get = func(ctx context.Context) (data []byte, err error) {
return media.NewTransferer(tc.client.API()).WithPeerPhoto(chat, chatPhoto.PhotoID).DownloadBytes(ctx)
}
}
return avatar, nil
}
func (tc *TelegramClient) convertPhoto(ctx context.Context, peerType ids.PeerType, peerID int64, photoClass tg.PhotoClass) (*bridgev2.Avatar, error) {
photo, ok := photoClass.(*tg.Photo)
if !ok {
return nil, fmt.Errorf("not a photo: %T", photoClass)
}
avatar := &bridgev2.Avatar{
ID: ids.MakeAvatarID(photo.GetID()),
}
if tc.main.useDirectMedia {
mediaID, err := ids.DirectMediaInfo{
PeerType: peerType,
PeerID: peerID,
UserID: tc.telegramUserID,
ID: photo.GetID(),
}.AsMediaID()
if err != nil {
return nil, err
}
if avatar.MXC, err = tc.main.Bridge.Matrix.GenerateContentURI(ctx, mediaID); err != nil {
return nil, err
}
avatar.Hash = ids.HashMediaID(mediaID)
} else {
avatar.Get = func(ctx context.Context) (data []byte, err error) {
return media.NewTransferer(tc.client.API()).WithPhoto(photo).DownloadBytes(ctx)
}
}
return avatar, nil
}