// 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
%s%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( `%s`, 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( `%s`, portal.MXID.URI().MatrixToURL(), html.EscapeString(fwdFromText), ) } else if fwd.FromName != "" { fwdFromText = fwd.FromName fwdFromHTML = fmt.Sprintf("%s", 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( `%s`, ghost.Intent.GetMXID().URI().MatrixToURL(), html.EscapeString(fwdFromText), ) } } // TODO fetch channel if not found } if fwdFromText == "" && fwd.FromName != "" { fwdFromText = fwd.FromName fwdFromHTML = fmt.Sprintf("%s", 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("
%s", 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 && (!isSticker || tc.main.Config.AnimatedSticker.Target == "disable") { 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 %s: %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: %s`, 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("