Files
mautrix-telegram/pkg/connector/tomatrix.go
T
Toni Spets 7e75c8ef83 media: make all media direct downloadable
The only exception is emojis.

Also changed direct download encoding field names to be more generic
when used in mixed manner depending on peer type.

Direct downloads are still somewhat inefficient as they require an API
round trip to succeed but we can cache things in the database if needed.
2025-05-07 06:43:51 +03:00

865 lines
27 KiB
Go

// 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 (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"html"
"strings"
"time"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"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/telegramfmt"
"go.mau.fi/mautrix-telegram/pkg/connector/util"
"go.mau.fi/mautrix-telegram/pkg/connector/waveform"
)
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:
return binary.BigEndian.AppendUint64(nil, uint64(media.Photo.GetID()))
case *tg.MessageMediaDocument:
return binary.BigEndian.AppendUint64(nil, uint64(media.Document.GetID()))
default:
zerolog.Ctx(ctx).Error().Type("media_type", m).Msg("Attempted to get hash for unsupported media type ID")
}
return nil
}
func (c *TelegramClient) mediaToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message) (*bridgev2.ConvertedMessagePart, *database.DisappearingSetting, []byte, error) {
media, ok := msg.GetMedia()
if !ok {
return nil, nil, nil, nil
}
switch media.TypeID() {
case tg.MessageMediaWebPageTypeID:
// Already handled in the message handling
return nil, 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, nil
case tg.MessageMediaPhotoTypeID, tg.MessageMediaDocumentTypeID:
converted, disappearingSetting, err := c.convertMediaRequiringUpload(ctx, portal, intent, msg.ID, media)
return converted, disappearingSetting, mediaHashID(ctx, media), err
case tg.MessageMediaContactTypeID:
return c.convertContact(media), nil, nil, nil
case tg.MessageMediaGeoTypeID, tg.MessageMediaGeoLiveTypeID, tg.MessageMediaVenueTypeID:
location, err := convertLocation(media)
return location, nil, nil, err
case tg.MessageMediaPollTypeID:
return convertPoll(media), nil, nil, nil
case tg.MessageMediaDiceTypeID:
return convertDice(media), nil, nil, nil
case tg.MessageMediaGameTypeID:
return convertGame(media), nil, nil, nil
case tg.MessageMediaStoryTypeID, tg.MessageMediaInvoiceTypeID, tg.MessageMediaGiveawayTypeID, tg.MessageMediaGiveawayResultsTypeID:
// 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, nil
default:
return nil, nil, nil, fmt.Errorf("unsupported media type %T", media)
}
}
func (c *TelegramClient) convertToMatrixWithRefetch(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message) (cm *bridgev2.ConvertedMessage, err error) {
cm, err = c.convertToMatrix(ctx, portal, intent, msg)
if !tgerr.Is(err, tg.ErrFileReferenceExpired) {
return cm, err
}
// If the error is that the file reference expired, refetch the message and
// try to convert it again.
log := zerolog.Ctx(ctx).With().Bool("message_refetch", true).Logger()
ctx = log.WithContext(ctx)
log.Warn().Err(err).Msg("Refetching message to convert media")
// TODO deduplicate this with the direct download code
var m tg.MessagesMessagesClass
peerType, id, err := ids.ParsePortalID(portal.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse portal ID: %w", err)
} else if peerType == ids.PeerTypeChannel {
var accessHash int64
accessHash, err = c.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id)
if err != nil {
return nil, fmt.Errorf("failed to get channel access hash: %w", err)
}
m, err = c.client.API().ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{
ChannelID: id,
AccessHash: accessHash,
},
ID: []tg.InputMessageClass{
&tg.InputMessageID{ID: msg.ID},
},
})
} else {
m, err = c.client.API().MessagesGetMessages(ctx, []tg.InputMessageClass{
&tg.InputMessageID{ID: msg.ID},
})
}
if err != nil {
return nil, err
} else if messages, ok := m.(tg.ModifiedMessagesMessages); !ok {
return nil, fmt.Errorf("unsupported messages type %T", messages)
} else if len(messages.GetMessages()) != 1 {
return nil, fmt.Errorf("wrong number of messages retrieved %d", len(messages.GetMessages()))
} else if refetchedMsg, ok := messages.GetMessages()[0].(*tg.Message); !ok {
return nil, fmt.Errorf("message was of the wrong type %s", messages.GetMessages()[0].TypeName())
} else if refetchedMsg.ID != msg.ID {
return nil, fmt.Errorf("no media found with ID %d", msg.ID)
} else {
return c.convertToMatrix(ctx, portal, intent, refetchedMsg)
}
}
func (c *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 c.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, err
} else if peerType == ids.PeerTypeChannel && !portal.Metadata.(*PortalMetadata).IsSuperGroup {
var sender *networkid.UserID
if msg.Out {
sender = &c.userID
} else if fromID, ok := msg.GetFromID(); ok {
sender = ptr.Ptr(c.getPeerSender(fromID).Sender)
}
if sender != nil {
profile, err := portal.PerMessageProfileForSender(ctx, *sender)
if err != nil {
return nil, err
}
perMessageProfile = &profile
}
}
cm = &bridgev2.ConvertedMessage{}
hasher := sha256.New()
if len(msg.Message) > 0 {
hasher.Write([]byte(msg.Message))
content, err := c.parseBodyAndHTML(ctx, msg.Message, msg.Entities)
if err != nil {
return nil, err
}
if media, ok := msg.GetMedia(); ok && media.TypeID() == tg.MessageMediaWebPageTypeID {
webpageCtx, webpageCtxCancel := context.WithTimeout(ctx, time.Second*5)
defer webpageCtxCancel()
preview, err := c.webpageToBeeperLinkPreview(webpageCtx, intent, media)
if err != nil {
log.Err(err).Msg("error converting 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, err := c.mediaToMatrix(ctx, portal, intent, msg)
if err != nil {
return nil, err
} else if mediaPart != nil {
hasher.Write(mediaHashID)
cm.Parts = append(cm.Parts, mediaPart)
cm.MergeCaption()
contentURI = mediaPart.Content.URL
if contentURI == "" && mediaPart.Content.File != nil {
contentURI = mediaPart.Content.File.URL
}
if disappearingSetting != nil {
cm.Disappear = *disappearingSetting
}
}
cm.Parts[0].Content.BeeperPerMessageProfile = perMessageProfile
cm.Parts[0].DBMetadata = &MessageMetadata{
ContentHash: hasher.Sum(nil),
ContentURI: contentURI,
}
if replyTo, ok := msg.GetReplyTo(); ok {
switch replyTo := replyTo.(type) {
case *tg.MessageReplyHeader:
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)
}
default:
log.Warn().Type("reply_to", replyTo).Msg("unhandled reply to type")
}
}
if disappearingSetting == nil {
// The TTL is either included in the message, or it's on the portal's
// metadata.
if ttl, ok := msg.GetTTLPeriod(); ok {
cm.Disappear = database.DisappearingSetting{
Type: database.DisappearingTypeAfterSend,
Timer: time.Duration(ttl) * time.Second,
}
} else if portal.Metadata.(*PortalMetadata).MessagesTTL > 0 {
cm.Disappear = database.DisappearingSetting{
Type: database.DisappearingTypeAfterSend,
Timer: time.Duration(ttl) * time.Second,
}
}
}
return
}
func (t *TelegramClient) parseBodyAndHTML(ctx context.Context, message string, entities []tg.MessageEntityClass) (*event.MessageEventContent, error) {
if len(entities) == 0 {
return &event.MessageEventContent{MsgType: event.MsgText, Body: message}, nil
}
var customEmojiIDs []int64
for _, entity := range entities {
switch entity := entity.(type) {
case *tg.MessageEntityCustomEmoji:
customEmojiIDs = append(customEmojiIDs, entity.DocumentID)
}
}
customEmojis, err := t.transferEmojisToMatrix(ctx, customEmojiIDs)
if err != nil {
return nil, err
}
return telegramfmt.Parse(ctx, message, entities, t.telegramFmtParams.WithCustomEmojis(customEmojis))
}
func (c *TelegramClient) webpageToBeeperLinkPreview(ctx context.Context, intent bridgev2.MatrixAPI, 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 pc, ok := webpage.GetPhoto(); ok && pc.TypeID() == tg.PhotoTypeID {
var fileInfo *event.FileInfo
preview.ImageURL, preview.ImageEncryption, fileInfo, err = media.NewTransferer(c.client.API()).
WithPhoto(pc).
Transfer(ctx, c.main.Store, intent)
if err != nil {
return nil, err
}
preview.ImageSize, preview.ImageWidth, preview.ImageHeight = fileInfo.Size, fileInfo.Width, fileInfo.Height
}
return preview, nil
}
func (c *TelegramClient) convertMediaRequiringUpload(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msgID int, msgMedia tg.MessageMediaClass) (*bridgev2.ConvertedMessagePart, *database.DisappearingSetting, error) {
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{}
transferer := media.NewTransferer(c.client.API()).WithRoomID(portal.MXID)
var mediaTransferer *media.ReadyTransferer
var disappearingSetting *database.DisappearingSetting
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 c.main.Config.DisableViewOnce {
return &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),
},
}, nil, nil
}
}
if c.main.Config.DisableDisappearing {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("You received a disappearing %s. For added privacy, you can only open it on the Telegram app.", typeName),
},
}, nil, nil
}
disappearingSetting = &database.DisappearingSetting{
Type: database.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"
}
telegramMediaID = msgMedia.Photo.GetID()
mediaTransferer = transferer.WithPhoto(msgMedia.Photo)
case *tg.MessageMediaDocument:
document, ok := msgMedia.Document.(*tg.Document)
if !ok {
return nil, nil, fmt.Errorf("unrecognized document type %T", msgMedia.Document)
}
telegramMediaID = document.GetID()
content.MsgType = event.MsgFile
extraInfo := map[string]any{}
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:
isVideo = true
content.MsgType = event.MsgVideo
transferer = transferer.WithVideo(a)
if a.RoundMessage {
extraInfo["fi.mau.telegram.round_message"] = a.RoundMessage
}
extraInfo["duration"] = int(a.Duration * 1000)
case *tg.DocumentAttributeAudio:
if content.MsgType != event.MsgVideo {
content.MsgType = event.MsgAudio
extraInfo["duration"] = int(a.Duration * 1000) // 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 {
content.MSC3245Voice = &event.MSC3245Voice{}
}
case *tg.DocumentAttributeImageSize:
transferer = transferer.WithImageSize(a)
case *tg.DocumentAttributeSticker:
isSticker = true
if content.Body == "" {
content.Body = a.Alt
} else {
content.FileName = content.Body
content.Body = a.Alt
}
stickerInfo := map[string]any{"alt": a.Alt, "id": document.ID}
if setID, ok := a.Stickerset.(*tg.InputStickerSetID); ok {
stickerInfo["pack"] = map[string]any{
"id": setID.ID,
"access_hash": setID.AccessHash,
}
} else if shortName, ok := a.Stickerset.(*tg.InputStickerSetShortName); ok {
stickerInfo["pack"] = map[string]any{
"short_name": shortName.ShortName,
}
}
extraInfo["fi.mau.telegram.sticker"] = stickerInfo
transferer = transferer.WithStickerConfig(c.main.Config.AnimatedSticker)
case *tg.DocumentAttributeAnimated:
isVideoGif = true
extraInfo["fi.mau.telegram.gif"] = true
}
}
if content.FileName == "" {
if content.Body != "" {
content.FileName = content.Body
} else {
content.Body = "file"
}
}
if isSticker {
// Strip filename so that we never render the caption
content.FileName = ""
if c.main.Config.AnimatedSticker.Target == "webm" || (isVideo && !c.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 isVideoGif {
extraInfo["fi.mau.gif"] = true
extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true
extraInfo["fi.mau.hide_controls"] = true
extraInfo["fi.mau.no_audio"] = true
}
extra["info"] = extraInfo
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(c.client.API()).
WithRoomID(portal.MXID).
WithDocument(document, true)
if c.main.useDirectMedia {
thumbnailURL, thumbnailInfo, err = thumbnailTransferer.DirectDownloadURL(ctx, c.telegramUserID, portal, msgID, true, document.ID)
if err != nil {
log.Err(err).Msg("error getting direct download URL for thumbnail")
}
}
if thumbnailURL == "" {
thumbnailURL, thumbnailFile, thumbnailInfo, err = thumbnailTransferer.Transfer(ctx, c.main.Store, intent)
if err != nil {
return nil, nil, fmt.Errorf("error transferring thumbnail: %w", err)
}
}
transferer = transferer.WithThumbnail(thumbnailURL, thumbnailFile, thumbnailInfo)
}
mediaTransferer = transferer.
WithFilename(content.Body).
WithDocument(msgMedia.Document, false)
default:
return nil, nil, fmt.Errorf("unhandled media type %T", msgMedia)
}
var err error
if c.main.useDirectMedia && (!isSticker || c.main.Config.AnimatedSticker.Target == "disable") {
content.URL, content.Info, err = mediaTransferer.DirectDownloadURL(ctx, c.telegramUserID, portal, msgID, false, telegramMediaID)
if err != nil {
log.Err(err).Msg("error getting direct download URL for media")
}
}
if content.URL == "" {
content.URL, content.File, content.Info, err = mediaTransferer.Transfer(ctx, c.main.Store, intent)
if err != nil {
return nil, nil, fmt.Errorf("error transferring media: %w", err)
}
if msgMedia.TypeID() == tg.MessageMediaPhotoTypeID {
content.Body = content.Body + exmime.ExtensionFromMimetype(content.Info.MimeType)
}
}
// 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",
}
if extra["info"] == nil {
extra["info"] = map[string]any{}
}
info := extra["info"].(map[string]any)
info["fi.mau.telegram.spoiler"] = true
}
return &bridgev2.ConvertedMessagePart{
Type: eventType,
Content: &content,
Extra: extra,
}, disappearingSetting, nil
}
func (c *TelegramClient) convertContact(media tg.MessageMediaClass) *bridgev2.ConvertedMessagePart {
contact := media.(*tg.MessageMediaContact)
name := util.FormatFullName(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`,
c.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, error) {
g, ok := media.(hasGeo)
if !ok || g.GetGeo().TypeID() != tg.GeoPointTypeID {
return nil, fmt.Errorf("location didn't have geo or geo is wrong type")
}
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,
}, nil
}
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 {
textAnswers = append(textAnswers, fmt.Sprintf("%d. %s", i+1, opt.Text.Text))
htmlAnswers.WriteString(fmt.Sprintf("<li>%s</li>", opt.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 (c *TelegramClient) convertUserProfilePhoto(ctx context.Context, userID int64, photo *tg.UserProfilePhoto) (*bridgev2.Avatar, error) {
avatar := &bridgev2.Avatar{
ID: ids.MakeAvatarID(photo.PhotoID),
}
if c.main.useDirectMedia {
mediaID, err := ids.DirectMediaInfo{
PeerType: ids.PeerTypeUser,
PeerID: userID,
UserID: c.telegramUserID,
ID: photo.PhotoID,
}.AsMediaID()
if err != nil {
return nil, err
}
if avatar.MXC, err = c.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) {
transferer, err := media.NewTransferer(c.client.API()).WithUserPhoto(ctx, c.ScopedStore, userID, photo.PhotoID)
if err != nil {
return nil, err
}
return transferer.DownloadBytes(ctx)
}
}
return avatar, nil
}
func (c *TelegramClient) convertChatPhoto(ctx context.Context, channelID, accessHash int64, chatPhoto *tg.ChatPhoto) (*bridgev2.Avatar, error) {
avatar := &bridgev2.Avatar{
ID: ids.MakeAvatarID(chatPhoto.PhotoID),
}
if c.main.useDirectMedia {
mediaID, err := ids.DirectMediaInfo{
PeerType: ids.PeerTypeChannel,
PeerID: channelID,
UserID: c.telegramUserID,
ID: chatPhoto.PhotoID,
}.AsMediaID()
if err != nil {
return nil, err
}
if avatar.MXC, err = c.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(c.client.API()).WithChannelPhoto(channelID, accessHash, chatPhoto.PhotoID).DownloadBytes(ctx)
}
}
return avatar, nil
}
func (c *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 c.main.useDirectMedia {
mediaID, err := ids.DirectMediaInfo{
PeerType: peerType,
PeerID: peerID,
UserID: c.telegramUserID,
ID: photo.GetID(),
}.AsMediaID()
if err != nil {
return nil, err
}
if avatar.MXC, err = c.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(c.client.API()).WithPhoto(photo).DownloadBytes(ctx)
}
}
return avatar, nil
}