From 7b5c057dcf0155c7af53b42c6ed87cd342850f7b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 26 Feb 2023 23:47:01 +0200 Subject: [PATCH] Refactor message handling to fully use convert pattern --- portal.go | 598 +++------------------------------------------- portal_convert.go | 519 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 556 insertions(+), 561 deletions(-) create mode 100644 portal_convert.go diff --git a/portal.go b/portal.go index 6d22888..8f9ba1c 100644 --- a/portal.go +++ b/portal.go @@ -3,7 +3,6 @@ package main import ( "errors" "fmt" - "html" "reflect" "strconv" "strings" @@ -22,7 +21,6 @@ import ( "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/util" "maunium.net/go/mautrix/util/variationselector" @@ -536,517 +534,7 @@ func (portal *Portal) markMessageHandled(discordID string, editIndex int, author msg.MassInsert(parts) } -func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent { - return &event.MessageEventContent{ - Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr), - MsgType: event.MsgNotice, - } -} - -func (portal *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr error) id.EventID { - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, portal.createMediaFailedMessage(bridgeErr), nil, 0) - if err != nil { - portal.log.Warnfln("Failed to send media error message to matrix: %v", err) - return "" - } - return resp.EventID -} - -const DiscordStickerSize = 160 - -func (portal *Portal) handleDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { - meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType} - if typeName == "sticker" && content.Info.MimeType == "application/json" { - meta.Converter = portal.bridge.convertLottie - } - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta) - if err != nil { - errorEventID := portal.sendMediaFailedMessage(intent, err) - if errorEventID != "" { - return &database.MessagePart{ - AttachmentID: id, - MXID: errorEventID, - } - } - return nil - } - if typeName == "sticker" && content.Info.MimeType == "application/json" { - content.Info.MimeType = dbFile.MimeType - } - content.Info.Size = dbFile.Size - if content.Info.Width == 0 && content.Info.Height == 0 { - content.Info.Width = dbFile.Width - content.Info.Height = dbFile.Height - } - if content.Info.Width == 0 && content.Info.Height == 0 && typeName == "sticker" { - content.Info.Width = DiscordStickerSize - content.Info.Height = DiscordStickerSize - } - if dbFile.DecryptionInfo != nil { - content.File = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - content.URL = dbFile.MXC.CUString() - } - - evtType := event.EventMessage - if typeName == "sticker" && (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 - } - evtType = event.EventSticker - } else if typeName == "sticker" { - evtType = event.EventSticker - } - - resp, err := portal.sendMatrixMessage(intent, evtType, content, nil, ts.UnixMilli()) - if err != nil { - portal.log.Warnfln("Failed to send %s to Matrix: %v", typeName, err) - return nil - } - // Update the fallback reply event for the next attachment - if threadRelation != nil { - threadRelation.InReplyTo.EventID = resp.EventID - } - return &database.MessagePart{ - AttachmentID: id, - MXID: resp.EventID, - } -} - -func (portal *Portal) handleDiscordSticker(intent *appservice.IntentAPI, sticker *discordgo.Sticker, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { - 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: - portal.log.Warnfln("Unknown sticker format %d in %s", sticker.FormatType, sticker.ID) - } - content := &event.MessageEventContent{ - Body: sticker.Name, // TODO find description from somewhere? - Info: &event.FileInfo{ - MimeType: mime, - }, - RelatesTo: threadRelation, - } - return portal.handleDiscordFile("sticker", intent, sticker.ID, sticker.URL(), content, ts, threadRelation) -} - -func (portal *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, att *discordgo.MessageAttachment, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { - // var captionContent *event.MessageEventContent - - // if att.Description != "" { - // captionContent = &event.MessageEventContent{ - // Body: att.Description, - // MsgType: event.MsgNotice, - // } - // } - // portal.Log.Debugfln("captionContent: %#v", captionContent) - - content := &event.MessageEventContent{ - Body: att.Filename, - Info: &event.FileInfo{ - Height: att.Height, - MimeType: att.ContentType, - Width: att.Width, - - // This gets overwritten later after the file is uploaded to the homeserver - Size: att.Size, - }, - RelatesTo: threadRelation, - } - - switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { - case "audio": - content.MsgType = event.MsgAudio - case "image": - content.MsgType = event.MsgImage - case "video": - content.MsgType = event.MsgVideo - default: - content.MsgType = event.MsgFile - } - return portal.handleDiscordFile("attachment", intent, att.ID, att.URL, content, ts, threadRelation) -} - -type ConvertedMessage struct { - Content *event.MessageEventContent - Extra map[string]any -} - -func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta) - if err != nil { - return &ConvertedMessage{Content: portal.createMediaFailedMessage(err)} - } - - content := &event.MessageEventContent{ - MsgType: event.MsgVideo, - Body: embed.URL, - Info: &event.FileInfo{ - Width: embed.Video.Width, - Height: embed.Video.Height, - MimeType: dbFile.MimeType, - - Size: dbFile.Size, - }, - } - if content.Info.Width == 0 && content.Info.Height == 0 { - content.Info.Width = dbFile.Width - content.Info.Height = dbFile.Height - } - if dbFile.DecryptionInfo != nil { - content.File = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - content.URL = dbFile.MXC.CUString() - } - extra := map[string]any{} - if embed.Type == discordgo.EmbedTypeGifv { - extra["info"] = map[string]any{ - "fi.mau.discord.gifv": true, - "fi.mau.loop": true, - "fi.mau.autoplay": true, - "fi.mau.hide_controls": true, - "fi.mau.no_audio": true, - } - } - return &ConvertedMessage{Content: content, Extra: extra} -} - -func (portal *Portal) handleDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { - content := portal.convertDiscordVideoEmbed(intent, embed) - content.Content.RelatesTo = threadRelation - - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, content.Content, content.Extra, ts.UnixMilli()) - if err != nil { - portal.log.Warnfln("Failed to send embed #%d of message %s to Matrix: %v", index+1, msgID, err) - return nil - } - - // Update the fallback reply event for the next attachment - if threadRelation != nil { - threadRelation.InReplyTo.EventID = resp.EventID - } - - return &database.MessagePart{ - AttachmentID: fmt.Sprintf("video_%s", embed.URL), - MXID: resp.EventID, - } -} - -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 (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string { - 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 != "" { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta) - if err != nil { - portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err) - } else { - authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML) - } - } - htmlParts = append(htmlParts, authorHTML) - } - if embed.Title != "" { - var titleHTML string - baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(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, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true))) - } - for i := 0; i < len(embed.Fields); i++ { - item := embed.Fields[i] - if portal.bridge.Config.Bridge.EmbedFieldsAsTables { - 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, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) - contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(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), - portal.renderDiscordMarkdownOnlyHTML(item.Name, false), - portal.renderDiscordMarkdownOnlyHTML(item.Value, true), - )) - } - } - if embed.Image != nil { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta) - if err != nil { - portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err) - } else { - htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC)) - } - } - var embedDateHTML string - if embed.Timestamp != "" { - formattedTime := embed.Timestamp - parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) - if err != nil { - portal.log.Warnfln("Failed to parse timestamp in embed #%d of message %s: %v", index+1, msgID, err) - } 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 != "" { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta) - if err != nil { - portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err) - } else { - footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.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 -} - -type BeeperLinkPreview struct { - mautrix.RespPreviewURL - MatchedURL string `json:"matched_url"` - ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` -} - -func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) { - dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta) - if err != nil { - portal.log.Warnfln("Failed to copy image in URL preview: %v", err) - } else { - if width != 0 || height != 0 { - preview.ImageWidth = width - preview.ImageHeight = height - } else { - preview.ImageWidth = dbFile.Width - preview.ImageHeight = dbFile.Height - } - preview.ImageSize = dbFile.Size - preview.ImageType = dbFile.MimeType - if dbFile.Encrypted { - preview.ImageEncryption = &event.EncryptedFileInfo{ - EncryptedFile: *dbFile.DecryptionInfo, - URL: dbFile.MXC.CUString(), - } - } else { - preview.ImageURL = dbFile.MXC.CUString() - } - } -} - -func (portal *Portal) convertDiscordLinkEmbedToBeeper(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview { - var preview BeeperLinkPreview - preview.MatchedURL = embed.URL - preview.Title = embed.Title - preview.Description = embed.Description - if embed.Image != nil { - portal.convertDiscordLinkEmbedImage(intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) - } else if embed.Thumbnail != nil { - portal.convertDiscordLinkEmbedImage(intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) - } - return &preview -} - -const msgInteractionTemplateHTML = `
-%s used /%s -
` - -const msgComponentTemplateHTML = `

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

` - -type BridgeEmbedType int - -const ( - EmbedUnknown BridgeEmbedType = iota - EmbedRich - EmbedLinkPreview - EmbedVideo -) - -func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { - // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview, - // so this is a hacky way to detect those. - return embed.Video != nil && embed.Video.ProxyURL == "" -} - -func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType { - switch embed.Type { - case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle: - return EmbedLinkPreview - case discordgo.EmbedTypeVideo: - if isActuallyLinkPreview(embed) { - return EmbedLinkPreview - } - return EmbedVideo - case discordgo.EmbedTypeGifv: - return EmbedVideo - case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage: - return EmbedRich - default: - return EmbedUnknown - } -} - -func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message, relation *event.RelatesTo, isEdit bool) *ConvertedMessage { - if msg.Type == discordgo.MessageTypeCall { - return &ConvertedMessage{Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "started a call", - }} - } else if msg.Type == discordgo.MessageTypeGuildMemberJoin { - return &ConvertedMessage{Content: &event.MessageEventContent{ - MsgType: event.MsgEmote, - Body: "joined the server", - }} - } - var htmlParts []string - if msg.Interaction != nil { - puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID) - puppet.UpdateInfo(nil, msg.Interaction.User) - htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name)) - } - if msg.Content != "" && !isPlainGifMessage(msg) { - htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false)) - } - previews := make([]*BeeperLinkPreview, 0) - for i, embed := range msg.Embeds { - switch getEmbedType(embed) { - case EmbedRich: - htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i)) - case EmbedLinkPreview: - previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed)) - case EmbedVideo: - // Ignore video embeds, they're handled as separate messages - default: - portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID) - } - } - - 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) - if relation != nil { - content.RelatesTo = relation.Copy() - } - extraContent := map[string]any{ - "com.beeper.linkpreviews": previews, - } - - if msg.MessageReference != nil && !isEdit { - //key := database.PortalKey{msg.MessageReference.ChannelID, user.ID} - replyTo := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.MessageReference.MessageID) - if len(replyTo) > 0 { - if content.RelatesTo == nil { - content.RelatesTo = &event.RelatesTo{} - } - content.RelatesTo.SetReplyTo(replyTo[0].MXID) - } - } - - return &ConvertedMessage{Content: &content, Extra: extraContent} -} - -func isPlainGifMessage(msg *discordgo.Message) bool { - return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv -} - func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message, thread *Thread) { - if portal.MXID == "" { - portal.log.Warnln("handle message called without a valid portal") - return - } - switch msg.Type { case discordgo.MessageTypeChannelNameChange, discordgo.MessageTypeChannelIconChange, discordgo.MessageTypeChannelPinnedMessage: // These are handled via channel updates @@ -1055,7 +543,6 @@ func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Mess portal.recentMessages.Push(msg.ID, msg) - // Handle normal message existing := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.ID) if existing != nil { portal.log.Debugln("Dropping duplicate message", msg.ID) @@ -1072,67 +559,56 @@ func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Mess puppet.UpdateInfo(user, msg.Author) intent := puppet.IntentFor(portal) - var threadRelation *event.RelatesTo - var threadID string + var discordThreadID string + var threadRootEvent, lastThreadEvent, replyToEvent id.EventID if thread != nil { - threadID = thread.ID - lastEventID := thread.RootMXID + discordThreadID = thread.ID + threadRootEvent = thread.RootMXID + lastThreadEvent = threadRootEvent lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID) if lastInThread != nil { - lastEventID = lastInThread.MXID + lastThreadEvent = lastInThread.MXID + } + } + + if msg.MessageReference != nil { + // This could be used to find cross-channel replies, but Matrix doesn't support those currently. + //key := database.PortalKey{msg.MessageReference.ChannelID, user.ID} + replyToMsg := portal.bridge.DB.Message.GetByDiscordID(portal.Key, msg.MessageReference.MessageID) + if len(replyToMsg) > 0 { + replyToEvent = replyToMsg[0].MXID } - threadRelation = (&event.RelatesTo{}).SetThread(thread.RootMXID, lastEventID) } - var parts []database.MessagePart ts, _ := discordgo.SnowflakeTimestamp(msg.ID) - textPart := portal.convertDiscordTextMessage(intent, msg, threadRelation, false) - if textPart != nil { - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, textPart.Content, textPart.Extra, ts.UnixMilli()) + parts := portal.convertDiscordMessage(intent, msg) + dbParts := make([]database.MessagePart, 0, len(parts)) + for i, part := range parts { + if (replyToEvent != "" || threadRootEvent != "") && part.Content.RelatesTo == nil { + part.Content.RelatesTo = &event.RelatesTo{} + } + if threadRootEvent != "" { + part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent) + } + if replyToEvent != "" { + part.Content.RelatesTo.SetReplyTo(replyToEvent) + // Only set reply for first event + replyToEvent = "" + } + resp, err := portal.sendMatrixMessage(intent, part.Type, part.Content, part.Extra, ts.UnixMilli()) if err != nil { - portal.log.Warnfln("Failed to send message %s to matrix: %v", msg.ID, err) - return - } - - parts = append(parts, database.MessagePart{MXID: resp.EventID}) - // Update the fallback reply event for attachments - if threadRelation != nil { - threadRelation.InReplyTo.EventID = resp.EventID - } - go portal.sendDeliveryReceipt(resp.EventID) - } - for _, att := range msg.Attachments { - part := portal.handleDiscordAttachment(intent, att, ts, threadRelation) - if part != nil { - parts = append(parts, *part) - } - } - for _, sticker := range msg.StickerItems { - part := portal.handleDiscordSticker(intent, sticker, ts, threadRelation) - if part != nil { - parts = append(parts, *part) - } - } - handledURLs := make(map[string]struct{}) - for i, embed := range msg.Embeds { - // Ignore non-video embeds, they're handled in convertDiscordTextMessage - if getEmbedType(embed) != EmbedVideo { + portal.log.Errorfln("Failed to send part #%d (attachment ID %q) of message %s to Matrix: %v", i+1, part.AttachmentID, msg.ID) continue } - // Discord deduplicates embeds by URL. It makes things easier for us too. - if _, handled := handledURLs[embed.URL]; handled { - continue - } - handledURLs[embed.URL] = struct{}{} - part := portal.handleDiscordVideoEmbed(intent, embed, msg.ID, i, ts, threadRelation) - if part != nil { - parts = append(parts, *part) - } + lastThreadEvent = resp.EventID + dbParts = append(dbParts, database.MessagePart{AttachmentID: part.AttachmentID, MXID: resp.EventID}) } if len(parts) == 0 { portal.log.Warnfln("Unhandled message %s (type %d)", msg.ID, msg.Type) + } else if len(dbParts) == 0 { + portal.log.Warnfln("All parts of message %s failed to send to Matrix", msg.ID) } else { - portal.markMessageHandled(msg.ID, 0, msg.Author.ID, ts, threadID, parts) + portal.markMessageHandled(msg.ID, 0, msg.Author.ID, ts, discordThreadID, dbParts) } } @@ -1265,7 +741,7 @@ func (portal *Portal) handleDiscordMessageUpdate(user *User, msg *discordgo.Mess if isPlainGifMessage(msg) { converted = portal.convertDiscordVideoEmbed(intent, msg.Embeds[0]) } else { - converted = portal.convertDiscordTextMessage(intent, msg, nil, true) + converted = portal.convertDiscordTextMessage(intent, msg) } if converted == nil { portal.log.Debugfln("Dropping non-text edit to %s (message on matrix: %t, text on discord: %t)", msg.ID, existing[0].AttachmentID == "", len(msg.Content) > 0) diff --git a/portal_convert.go b/portal_convert.go new file mode 100644 index 0000000..b8a7a82 --- /dev/null +++ b/portal_convert.go @@ -0,0 +1,519 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2023 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 main + +import ( + "fmt" + "html" + "strconv" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +type ConvertedMessage struct { + AttachmentID string + + Type event.Type + Content *event.MessageEventContent + Extra map[string]any +} + +func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent { + return &event.MessageEventContent{ + Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr), + MsgType: event.MsgNotice, + } +} + +const DiscordStickerSize = 160 + +func (portal *Portal) convertDiscordFile(typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent { + meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType} + if typeName == "sticker" && content.Info.MimeType == "application/json" { + meta.Converter = portal.bridge.convertLottie + } + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta) + if err != nil { + portal.log.Errorfln("Error copying attachment %s to Matrix: %v", id, err) + return portal.createMediaFailedMessage(err) + } + if typeName == "sticker" && content.Info.MimeType == "application/json" { + content.Info.MimeType = dbFile.MimeType + } + content.Info.Size = dbFile.Size + if content.Info.Width == 0 && content.Info.Height == 0 { + content.Info.Width = dbFile.Width + content.Info.Height = dbFile.Height + } + if content.Info.Width == 0 && content.Info.Height == 0 && typeName == "sticker" { + content.Info.Width = DiscordStickerSize + content.Info.Height = DiscordStickerSize + } + if dbFile.DecryptionInfo != nil { + content.File = &event.EncryptedFileInfo{ + EncryptedFile: *dbFile.DecryptionInfo, + URL: dbFile.MXC.CUString(), + } + } else { + content.URL = dbFile.MXC.CUString() + } + + if typeName == "sticker" && (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 + } + } + return content +} + +func (portal *Portal) convertDiscordSticker(intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage { + 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: + portal.log.Warnfln("Unknown sticker format %d in %s", sticker.FormatType, sticker.ID) + } + return &ConvertedMessage{ + Type: event.EventSticker, + Content: portal.convertDiscordFile("sticker", intent, sticker.ID, sticker.URL(), &event.MessageEventContent{ + Body: sticker.Name, // TODO find description from somewhere? + Info: &event.FileInfo{ + MimeType: mime, + }, + }), + } +} + +func (portal *Portal) convertDiscordAttachment(intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage { + content := &event.MessageEventContent{ + Body: att.Filename, + Info: &event.FileInfo{ + Height: att.Height, + MimeType: att.ContentType, + Width: att.Width, + + // This gets overwritten later after the file is uploaded to the homeserver + Size: att.Size, + }, + } + 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 + case "image": + content.MsgType = event.MsgImage + case "video": + content.MsgType = event.MsgVideo + default: + content.MsgType = event.MsgFile + } + content = portal.convertDiscordFile("attachment", intent, att.ID, att.URL, content) + return &ConvertedMessage{ + Type: event.EventMessage, + Content: content, + } +} + +func (portal *Portal) convertDiscordVideoEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage { + attachmentID := fmt.Sprintf("video_%s", embed.URL) + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta) + if err != nil { + return &ConvertedMessage{ + AttachmentID: attachmentID, + Type: event.EventMessage, + Content: portal.createMediaFailedMessage(err), + } + } + + content := &event.MessageEventContent{ + MsgType: event.MsgVideo, + Body: embed.URL, + Info: &event.FileInfo{ + Width: embed.Video.Width, + Height: embed.Video.Height, + MimeType: dbFile.MimeType, + + Size: dbFile.Size, + }, + } + if content.Info.Width == 0 && content.Info.Height == 0 { + content.Info.Width = dbFile.Width + content.Info.Height = dbFile.Height + } + if dbFile.DecryptionInfo != nil { + content.File = &event.EncryptedFileInfo{ + EncryptedFile: *dbFile.DecryptionInfo, + URL: dbFile.MXC.CUString(), + } + } else { + content.URL = dbFile.MXC.CUString() + } + extra := map[string]any{} + if embed.Type == discordgo.EmbedTypeGifv { + extra["info"] = map[string]any{ + "fi.mau.discord.gifv": true, + "fi.mau.loop": true, + "fi.mau.autoplay": true, + "fi.mau.hide_controls": true, + "fi.mau.no_audio": true, + } + } + return &ConvertedMessage{ + AttachmentID: attachmentID, + Type: event.EventMessage, + Content: content, + Extra: extra, + } +} + +func (portal *Portal) convertDiscordMessage(intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage { + predictedLength := len(msg.Attachments) + len(msg.StickerItems) + if msg.Content != "" { + predictedLength++ + } + parts := make([]*ConvertedMessage, 0, predictedLength) + if textPart := portal.convertDiscordTextMessage(intent, msg); textPart != nil { + parts = append(parts, textPart) + } + for _, att := range msg.Attachments { + if part := portal.convertDiscordAttachment(intent, att); part != nil { + parts = append(parts, part) + } + } + for _, sticker := range msg.StickerItems { + if part := portal.convertDiscordSticker(intent, sticker); part != nil { + parts = append(parts, part) + } + } + handledURLs := make(map[string]struct{}) + for _, embed := range msg.Embeds { + // Ignore non-video embeds, they're handled in convertDiscordTextMessage + if getEmbedType(embed) != EmbedVideo { + continue + } + // Discord deduplicates embeds by URL. It makes things easier for us too. + if _, handled := handledURLs[embed.URL]; handled { + continue + } + handledURLs[embed.URL] = struct{}{} + part := portal.convertDiscordVideoEmbed(intent, embed) + if part != nil { + parts = append(parts, part) + } + } + return parts +} + +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 (portal *Portal) convertDiscordRichEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string { + 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 != "" { + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta) + if err != nil { + portal.log.Warnfln("Failed to reupload author icon in embed #%d of message %s: %v", index+1, msgID, err) + } else { + authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML) + } + } + htmlParts = append(htmlParts, authorHTML) + } + if embed.Title != "" { + var titleHTML string + baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(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, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true))) + } + for i := 0; i < len(embed.Fields); i++ { + item := embed.Fields[i] + if portal.bridge.Config.Bridge.EmbedFieldsAsTables { + 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, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) + contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(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), + portal.renderDiscordMarkdownOnlyHTML(item.Name, false), + portal.renderDiscordMarkdownOnlyHTML(item.Value, true), + )) + } + } + if embed.Image != nil { + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta) + if err != nil { + portal.log.Warnfln("Failed to reupload image in embed #%d of message %s: %v", index+1, msgID, err) + } else { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC)) + } + } + var embedDateHTML string + if embed.Timestamp != "" { + formattedTime := embed.Timestamp + parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) + if err != nil { + portal.log.Warnfln("Failed to parse timestamp in embed #%d of message %s: %v", index+1, msgID, err) + } 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 != "" { + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta) + if err != nil { + portal.log.Warnfln("Failed to reupload footer icon in embed #%d of message %s: %v", index+1, msgID, err) + } else { + footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.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 +} + +type BeeperLinkPreview struct { + mautrix.RespPreviewURL + MatchedURL string `json:"matched_url"` + ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` +} + +func (portal *Portal) convertDiscordLinkEmbedImage(intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) { + dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta) + if err != nil { + portal.log.Warnfln("Failed to copy image in URL preview: %v", err) + } else { + if width != 0 || height != 0 { + preview.ImageWidth = width + preview.ImageHeight = height + } else { + preview.ImageWidth = dbFile.Width + preview.ImageHeight = dbFile.Height + } + preview.ImageSize = dbFile.Size + preview.ImageType = dbFile.MimeType + if dbFile.Encrypted { + preview.ImageEncryption = &event.EncryptedFileInfo{ + EncryptedFile: *dbFile.DecryptionInfo, + URL: dbFile.MXC.CUString(), + } + } else { + preview.ImageURL = dbFile.MXC.CUString() + } + } +} + +func (portal *Portal) convertDiscordLinkEmbedToBeeper(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview { + var preview BeeperLinkPreview + preview.MatchedURL = embed.URL + preview.Title = embed.Title + preview.Description = embed.Description + if embed.Image != nil { + portal.convertDiscordLinkEmbedImage(intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) + } else if embed.Thumbnail != nil { + portal.convertDiscordLinkEmbedImage(intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) + } + return &preview +} + +const msgInteractionTemplateHTML = `
+%s used /%s +
` + +const msgComponentTemplateHTML = `

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

` + +type BridgeEmbedType int + +const ( + EmbedUnknown BridgeEmbedType = iota + EmbedRich + EmbedLinkPreview + EmbedVideo +) + +func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { + // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview, + // so this is a hacky way to detect those. + return embed.Video != nil && embed.Video.ProxyURL == "" +} + +func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType { + switch embed.Type { + case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle: + return EmbedLinkPreview + case discordgo.EmbedTypeVideo: + if isActuallyLinkPreview(embed) { + return EmbedLinkPreview + } + return EmbedVideo + case discordgo.EmbedTypeGifv: + return EmbedVideo + case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage: + return EmbedRich + default: + return EmbedUnknown + } +} + +func isPlainGifMessage(msg *discordgo.Message) bool { + return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv +} + +func (portal *Portal) convertDiscordTextMessage(intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage { + if msg.Type == discordgo.MessageTypeCall { + return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "started a call", + }} + } else if msg.Type == discordgo.MessageTypeGuildMemberJoin { + return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "joined the server", + }} + } + var htmlParts []string + if msg.Interaction != nil { + puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID) + puppet.UpdateInfo(nil, msg.Interaction.User) + htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name)) + } + if msg.Content != "" && !isPlainGifMessage(msg) { + htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false)) + } + previews := make([]*BeeperLinkPreview, 0) + for i, embed := range msg.Embeds { + switch getEmbedType(embed) { + case EmbedRich: + htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(intent, embed, msg.ID, i)) + case EmbedLinkPreview: + previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(intent, embed)) + case EmbedVideo: + // Ignore video embeds, they're handled as separate messages + default: + portal.log.Warnfln("Unknown type %s in embed #%d of message %s", embed.Type, i+1, msg.ID) + } + } + + 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 &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent} +}