// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 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
` const msgInteractionTemplateHTML = `↷ Forwarded
%s%s
%s used /%s` const msgComponentTemplateHTML = `
This message contains interactive elements. Use the Discord app to interact with the message.
` func (mc *MessageConverter) tryAddingReplyToConvertedMessage( ctx context.Context, converted *bridgev2.ConvertedMessage, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message, ) { ref := msg.MessageReference if ref == nil { return } // TODO: Support threads. log := zerolog.Ctx(ctx).With(). Str("referenced_channel_id", ref.ChannelID). Str("referenced_guild_id", ref.GuildID). Str("referenced_message_id", ref.MessageID).Logger() // The portal containing the message that was replied to. targetPortal := portal if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) { var err error targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(ref.ChannelID)) if err != nil { log.Err(err).Msg("Failed to get cross-room reply portal; proceeding") return } if targetPortal == nil { return } } messageID := discordid.MakeMessageID(ref.MessageID) repliedToMatrixMsg, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, messageID) if err != nil { log.Err(err).Msg("Failed to query database for first message part; proceeding") return } if repliedToMatrixMsg == nil { log.Debug().Msg("Couldn't find a first message part for reply target; proceeding") return } converted.ReplyTo = &networkid.MessageOptionalPartID{ MessageID: repliedToMatrixMsg.ID, PartID: &repliedToMatrixMsg.PartID, } converted.ReplyToRoom = targetPortal.PortalKey converted.ReplyToUser = repliedToMatrixMsg.SenderID } func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { log := zerolog.Ctx(ctx) switch msg.Type { case discordgo.MessageTypeCall: return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "started a call", }} case discordgo.MessageTypeGuildMemberJoin: return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgEmote, Body: "joined the server", }} } var htmlParts []string if msg.Interaction != nil { ghost, err := mc.Bridge.GetGhostByID(ctx, discordid.MakeUserID(msg.Interaction.User.ID)) // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. if err == nil { htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) } else { log.Err(err).Msg("Couldn't get ghost by ID while bridging interaction") } } if msg.Content != "" && !isPlainGifMessage(msg) { // Bridge basic text messages. htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(portal, msg.Content, true)) } else if msg.MessageReference != nil && msg.MessageReference.Type == discordgo.MessageReferenceTypeForward && len(msg.MessageSnapshots) > 0 && msg.MessageSnapshots[0].Message != nil { // Bridge forwarded messages. htmlParts = append(htmlParts, mc.forwardedMessageHTMLPart(ctx, portal, source, msg)) } previews := make([]*event.BeeperLinkPreview, 0) for i, embed := range msg.Embeds { if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) { continue } with := log.With(). Str("embed_type", string(embed.Type)). Int("embed_index", i) switch getEmbedType(msg, embed) { case EmbedRich: log := with.Str("computed_embed_type", "rich").Logger() htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), embed)) case EmbedLinkPreview: log := with.Str("computed_embed_type", "link preview").Logger() previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), embed)) case EmbedVideo: // Video embeds are handled as separate messages via renderDiscordVideoEmbed. default: log := with.Logger() log.Warn().Msg("Unknown embed type in message") } } if len(msg.Components) > 0 { htmlParts = append(htmlParts, msgComponentTemplateHTML) } if len(htmlParts) == 0 { return nil } fullHTML := strings.Join(htmlParts, "\n") if !msg.MentionEveryone { fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om") } content := format.HTMLToContent(fullHTML) extraContent := map[string]any{ "com.beeper.linkpreviews": previews, } return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} } func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal *bridgev2.Portal, source *bridgev2.UserLogin, msg *discordgo.Message) string { log := zerolog.Ctx(ctx) forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true) msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") origLink := fmt.Sprintf("unknown channel • %s", msgTSText) if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakeChannelPortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { // We've bridged the message that was forwarded, so we can link to it directly. origLink = fmt.Sprintf( `#%s • %s`, forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) } else if err != nil { log.Err(err).Msg("Couldn't find corresponding message when bridging forwarded message") } else if forwardedFromPortal.MXID != "" { // We don't have the message but we have the portal, so link to that. origLink = fmt.Sprintf( `#%s • %s`, forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) } else if forwardedFromPortal.Name != "" { // We only have the name of the portal. origLink = fmt.Sprintf("%s • %s", forwardedFromPortal.Name, msgTSText) } } else if err != nil { log.Err(err).Msg("Couldn't find corresponding portal when bridging forwarded message") } return fmt.Sprintf(forwardTemplateHTML, forwardedHTML, origLink) } func mediaFailedMessage(err error) *event.MessageEventContent { return &event.MessageEventContent{ Body: fmt.Sprintf("Failed to bridge media: %v", err), MsgType: event.MsgNotice, } } func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { var proxyURL string if embed.Video != nil { proxyURL = embed.Video.ProxyURL } else if embed.Thumbnail != nil { proxyURL = embed.Thumbnail.ProxyURL } else { zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed") return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed", MsgType: event.MsgNotice, }, } } reupload, err := mc.ReuploadUnknownMedia(ctx, proxyURL, true) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: mediaFailedMessage(err), } } content := &event.MessageEventContent{ Body: embed.URL, URL: reupload.MXC, File: reupload.File, Info: &event.FileInfo{ MimeType: reupload.MimeType, Size: reupload.Size, }, } if embed.Video != nil { content.MsgType = event.MsgVideo content.Info.Width = embed.Video.Width content.Info.Height = embed.Video.Height } else { content.MsgType = event.MsgImage content.Info.Width = embed.Thumbnail.Width content.Info.Height = embed.Thumbnail.Height } extra := map[string]any{} if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv { extra["info"] = map[string]any{ "fi.mau.discord.gifv": true, "fi.mau.gif": true, "fi.mau.loop": true, "fi.mau.autoplay": true, "fi.mau.hide_controls": true, "fi.mau.no_audio": true, } } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: extra, } } func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { var mime string switch sticker.FormatType { case discordgo.StickerFormatTypePNG: mime = "image/png" case discordgo.StickerFormatTypeAPNG: mime = "image/apng" case discordgo.StickerFormatTypeLottie: mime = "application/json" case discordgo.StickerFormatTypeGIF: mime = "image/gif" default: zerolog.Ctx(ctx).Warn(). Int("sticker_format", int(sticker.FormatType)). Str("sticker_id", sticker.ID). Msg("Unknown sticker format") } // TODO(skip): Support direct media. reupload, err := mc.ReuploadMedia(ctx, sticker.URL(), mime, "", -1, true) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to copy sticker to Matrix") return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: mediaFailedMessage(err), } } content := &event.MessageEventContent{ Body: sticker.Name, // TODO(skip): Find description from somewhere? Info: &event.FileInfo{ MimeType: reupload.MimeType, Size: reupload.Size, }, } content.URL, content.File = reupload.MXC, reupload.File cleanupConvertedStickerInfo(content) return &bridgev2.ConvertedMessagePart{ Type: event.EventSticker, Content: content, } } const DiscordStickerSize = 160 func cleanupConvertedStickerInfo(content *event.MessageEventContent) { if content.Info == nil { return } if content.Info.Width == 0 && content.Info.Height == 0 { content.Info.Width = DiscordStickerSize content.Info.Height = DiscordStickerSize } else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize { if content.Info.Width > content.Info.Height { content.Info.Height /= content.Info.Width / DiscordStickerSize content.Info.Width = DiscordStickerSize } else if content.Info.Width < content.Info.Height { content.Info.Width /= content.Info.Height / DiscordStickerSize content.Info.Height = DiscordStickerSize } else { content.Info.Width = DiscordStickerSize content.Info.Height = DiscordStickerSize } } } const ( embedHTMLWrapper = `%s` embedHTMLWrapperColor = `
%s` embedHTMLAuthorWithImage = `` embedHTMLAuthorPlain = `` embedHTMLAuthorLink = `%s` embedHTMLTitleWithLink = `` embedHTMLTitlePlain = `` embedHTMLDescription = `` embedHTMLFieldName = `