// 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 . package msgconv import ( "context" "fmt" "html" "strconv" "strings" "time" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "go.mau.fi/mautrix-discord/pkg/attachment" "go.mau.fi/mautrix-discord/pkg/discordid" ) func (mc *MessageConverter) ToMatrix( ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, source *bridgev2.UserLogin, msg *discordgo.Message, ) *bridgev2.ConvertedMessage { predictedLength := len(msg.Attachments) + len(msg.StickerItems) if msg.Content != "" { predictedLength++ } parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength) if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil { parts = append(parts, textPart) } ctx = zerolog.Ctx(ctx).With(). Str("action", "convert discord message to matrix"). Str("message_id", msg.ID). Logger().WithContext(ctx) log := zerolog.Ctx(ctx) handledIDs := make(map[string]struct{}) for _, att := range msg.Attachments { if _, handled := handledIDs[att.ID]; handled { continue } handledIDs[att.ID] = struct{}{} log := log.With().Str("attachment_id", att.ID).Logger() if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, portal, att); part != nil { parts = append(parts, part) } } for _, sticker := range msg.StickerItems { if _, handled := handledIDs[sticker.ID]; handled { continue } handledIDs[sticker.ID] = struct{}{} log := log.With().Str("sticker_id", sticker.ID).Logger() if part := mc.renderDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil { parts = append(parts, part) } } for i, embed := range msg.Embeds { // Ignore non-video embeds, they're handled in convertDiscordTextMessage if getEmbedType(msg, embed) != EmbedVideo { continue } // Discord deduplicates embeds by URL. It makes things easier for us too. if _, handled := handledIDs[embed.URL]; handled { continue } handledIDs[embed.URL] = struct{}{} log := log.With(). Str("computed_embed_type", "video"). Str("embed_type", string(embed.Type)). Int("embed_index", i). Logger() part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, portal, embed) if part != nil { parts = append(parts, part) } } if len(parts) == 0 && msg.Thread != nil { parts = append(parts, &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgText, Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name), }}) } // TODO(skip): Add extra metadata. // for _, part := range parts { // puppet.addWebhookMeta(part, msg) // puppet.addMemberMeta(part, msg) // } // Assign incrementing part IDs. for i, part := range parts { part.ID = networkid.PartID(strconv.Itoa(i)) } return &bridgev2.ConvertedMessage{Parts: parts} } const forwardTemplateHTML = `

↷ Forwarded

%s

%s

` const msgInteractionTemplateHTML = `
%s used /%s
` const msgComponentTemplateHTML = `

This message contains interactive elements. Use the Discord app to interact with the message.

` 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, networkid.UserID(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), intent, portal, embed)) case EmbedLinkPreview: log := with.Str("computed_embed_type", "link preview").Logger() previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, portal, 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.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(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, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, 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, }, } } upload := attachment.AttachmentReupload{ DownloadingURL: proxyURL, } reupload, err := mc.ReuploadMedia(ctx, intent, portal, upload) 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, Info: &event.FileInfo{ MimeType: reupload.MimeType, Size: reupload.DownloadedSize, }, } if reupload.EncryptedFile != nil { content.File = reupload.EncryptedFile } else { content.URL = reupload.MXC } 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(context context.Context, intent bridgev2.MatrixAPI, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { panic("unimplemented") } const ( embedHTMLWrapper = `
%s
` embedHTMLWrapperColor = `
%s
` embedHTMLAuthorWithImage = `

 %s

` embedHTMLAuthorPlain = `

%s

` embedHTMLAuthorLink = `%s` embedHTMLTitleWithLink = `

%s

` embedHTMLTitlePlain = `

%s

` embedHTMLDescription = `

%s

` embedHTMLFieldName = `%s` embedHTMLFieldValue = `%s` embedHTMLFields = `%s%s
` embedHTMLLinearField = `

%s
%s

` embedHTMLImage = `

` embedHTMLFooterWithImage = `` embedHTMLFooterPlain = `` embedHTMLFooterOnlyDate = `` embedHTMLDate = `` embedFooterDateSeparator = ` • ` ) func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) string { log := zerolog.Ctx(ctx) var htmlParts []string if embed.Author != nil { var authorHTML string authorNameHTML := html.EscapeString(embed.Author.Name) if embed.Author.URL != "" { authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML) } authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) if embed.Author.ProxyIconURL != "" { reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Author.ProxyIconURL, }) if err != nil { log.Warn().Err(err).Msg("Failed to reupload author icon in embed") } else { authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, reupload.MXC, authorNameHTML) } } htmlParts = append(htmlParts, authorHTML) } if embed.Title != "" { var titleHTML string baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false) if embed.URL != "" { titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) } else { titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML) } htmlParts = append(htmlParts, titleHTML) } if embed.Description != "" { htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(portal, embed.Description, true))) } for i := 0; i < len(embed.Fields); i++ { item := embed.Fields[i] // TODO(skip): Port EmbedFieldsAsTables. if false { splitItems := []*discordgo.MessageEmbedField{item} if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { splitItems = append(splitItems, embed.Fields[i+1]) i++ if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline { splitItems = append(splitItems, embed.Fields[i+1]) i++ } } headerParts := make([]string, len(splitItems)) contentParts := make([]string, len(splitItems)) for j, splitItem := range splitItems { headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false)) contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true)) } htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) } else { htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, strconv.FormatBool(item.Inline), mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false), mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true), )) } } if embed.Image != nil { reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Image.ProxyURL, }) if err != nil { log.Warn().Err(err).Msg("Failed to reupload image in embed") } else { htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, reupload.MXC)) } } var embedDateHTML string if embed.Timestamp != "" { formattedTime := embed.Timestamp parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) if err != nil { log.Warn().Err(err).Msg("Failed to parse timestamp in embed") } else { formattedTime = parsedTS.Format(discordTimestampStyle('F').Format()) } embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime) } if embed.Footer != nil { var footerHTML string var datePart string if embedDateHTML != "" { datePart = embedFooterDateSeparator + embedDateHTML } footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) if embed.Footer.ProxyIconURL != "" { reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: embed.Footer.ProxyIconURL, }) if err != nil { log.Warn().Err(err).Msg("Failed to reupload footer icon in embed") } else { footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, reupload.MXC, html.EscapeString(embed.Footer.Text), datePart) } } htmlParts = append(htmlParts, footerHTML) } else if embed.Timestamp != "" { htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML)) } if len(htmlParts) == 0 { return "" } compiledHTML := strings.Join(htmlParts, "") if embed.Color != 0 { compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML) } else { compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML) } return compiledHTML } func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: url, }) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring") return } if width != 0 || height != 0 { preview.ImageWidth = event.IntOrString(width) preview.ImageHeight = event.IntOrString(height) } preview.ImageSize = event.IntOrString(reupload.DownloadedSize) preview.ImageType = reupload.MimeType if reupload.EncryptedFile != nil { preview.ImageEncryption = &event.EncryptedFileInfo{ EncryptedFile: reupload.EncryptedFile.EncryptedFile, URL: reupload.MXC, } } } func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { var preview event.BeeperLinkPreview preview.MatchedURL = embed.URL preview.Title = embed.Title preview.Description = embed.Description if embed.Image != nil { mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) } else if embed.Thumbnail != nil { mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) } return &preview } func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { content := &event.MessageEventContent{ Body: att.Filename, Info: &event.FileInfo{ Width: att.Width, Height: att.Height, MimeType: att.ContentType, // This gets overwritten later after the file is uploaded to the homeserver Size: att.Size, }, } var extra = make(map[string]any) if strings.HasPrefix(att.Filename, "SPOILER_") { extra["page.codeberg.everypizza.msc4193.spoiler"] = true } if att.Description != "" { content.Body = att.Description content.FileName = att.Filename } switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { case "audio": content.MsgType = event.MsgAudio if att.Waveform != nil { // Bridge a voice message. // TODO convert waveform extra["org.matrix.msc1767.audio"] = map[string]any{ "duration": int(att.DurationSeconds * 1000), } extra["org.matrix.msc3245.voice"] = map[string]any{} } case "image": content.MsgType = event.MsgImage case "video": content.MsgType = event.MsgVideo default: content.MsgType = event.MsgFile } // TODO(skip): Support direct media. reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{ DownloadingURL: att.URL, }) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: mediaFailedMessage(err), } } content.Info.Size = reupload.DownloadedSize if content.Info.Width == 0 && content.Info.Height == 0 { content.Info.Width = att.Width content.Info.Height = att.Height } if reupload.EncryptedFile != nil { content.File = reupload.EncryptedFile } else { content.URL = reupload.MXC } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: extra, } }