diff --git a/attachments.go b/attachments.go index 4d248bd..7c34768 100644 --- a/attachments.go +++ b/attachments.go @@ -10,6 +10,7 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/gabriel-vasile/mimetype" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" @@ -75,6 +76,9 @@ func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, da dbFile.URL = url dbFile.ID = attachmentID dbFile.Size = len(data) + if mime == "" { + mime = mimetype.Detect(data).String() + } if strings.HasPrefix(mime, "image/") { cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) dbFile.Width = cfg.Width diff --git a/config/bridge.go b/config/bridge.go index fb5cf33..797eedc 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -42,6 +42,7 @@ type BridgeConfig struct { MessageErrorNotices bool `yaml:"message_error_notices"` RestrictedRooms bool `yaml:"restricted_rooms"` AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"` + EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"` MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"` SyncDirectChatList bool `yaml:"sync_direct_chat_list"` ResendBridgeInfo bool `yaml:"resend_bridge_info"` diff --git a/config/upgrade.go b/config/upgrade.go index 99b892e..625e4f7 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -37,6 +37,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Bool, "bridge", "message_error_notices") helper.Copy(up.Bool, "bridge", "restricted_rooms") helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open") + helper.Copy(up.Bool, "bridge", "embed_fields_as_tables") helper.Copy(up.Bool, "bridge", "mute_channels_on_create") helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") helper.Copy(up.Bool, "bridge", "resend_bridge_info") diff --git a/example-config.yaml b/example-config.yaml index 52fb084..4f2dd41 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -115,6 +115,9 @@ bridge: # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix? # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4). autojoin_thread_on_open: true + # Should inline fields in Discord embeds be bridged as HTML tables to Matrix? + # Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI. + embed_fields_as_tables: true # Should guild channels be muted when the portal is created? This only meant for single-user instances, # it won't mute it for all users if there are multiple Matrix users in the same Discord guild. mute_channels_on_create: false diff --git a/formatter.go b/formatter.go index 2f2cfd6..4069972 100644 --- a/formatter.go +++ b/formatter.go @@ -34,6 +34,10 @@ var discordExtensions = goldmark.WithExtensions(mdext.SimpleSpoiler, mdext.Disco var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) func (portal *Portal) renderDiscordMarkdown(text string) event.MessageEventContent { + return format.HTMLToContent(portal.renderDiscordMarkdownOnlyHTML(text)) +} + +func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string) string { text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string { return s[:2] + `\` + s[2:] }) @@ -45,7 +49,13 @@ func (portal *Portal) renderDiscordMarkdown(text string) event.MessageEventConte format.Extensions, format.HTMLOptions, discordExtensions, goldmark.WithExtensions(&DiscordTag{portal}), ) - return format.RenderMarkdownCustom(text, mdRenderer) + + var buf strings.Builder + err := mdRenderer.Convert([]byte(text), &buf) + if err != nil { + panic(fmt.Errorf("markdown parser errored: %w", err)) + } + return format.UnwrapSingleParagraph(buf.String()) } const formatterContextUserKey = "fi.mau.discord.user" diff --git a/go.mod b/go.mod index e1bca7d..cd0a3b3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.18 require ( github.com/bwmarrin/discordgo v0.26.1 + github.com/gabriel-vasile/mimetype v1.4.1 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/lib/pq v1.10.7 diff --git a/go.sum b/go.sum index b0ba0f5..7ff060f 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= +github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= @@ -49,15 +51,20 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/portal.go b/portal.go index 2c9825c..e907956 100644 --- a/portal.go +++ b/portal.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "html" "reflect" "strconv" "strings" @@ -12,6 +13,7 @@ import ( "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/util/variationselector" "github.com/bwmarrin/discordgo" @@ -654,6 +656,139 @@ func (portal *Portal) handleDiscordAttachment(intent *appservice.IntentAPI, att return portal.handleDiscordFile("attachment", intent, att.ID, att.URL, content, ts, threadRelation) } +const ( + embedHTMLWrapper = `
%s
` + embedHTMLWrapperColor = `
%s
` + embedHTMLAuthorWithImage = `

Author icon %s

` + embedHTMLAuthorPlain = `

%s

` + embedHTMLAuthorLink = `%s` + embedHTMLTitleWithLink = `

%s

` + embedHTMLTitlePlain = `

%s

` + embedHTMLDescription = `

%s

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

%s
%s

` + embedHTMLFooterWithImage = `` + embedHTMLFooterPlain = `` + embedHTMLFooterOnlyDate = `` + embedHTMLDate = `` + embedFooterDateSeparator = ` • ` +) + +func (portal *Portal) handleDiscordEmbed(intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int, ts time.Time, threadRelation *event.RelatesTo) *database.MessagePart { + 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, "", "") + // TODO log error + if err == nil { + authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML) + } + } + htmlParts = append(htmlParts, authorHTML) + } + if embed.Title != "" { + var titleHTML string + baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title) + 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))) + } + 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)) + contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value)) + } + 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), + portal.renderDiscordMarkdownOnlyHTML(item.Value), + )) + } + } + var embedDateHTML string + if embed.Timestamp != "" { + formattedTime := embed.Timestamp + parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp) + // TODO log error? + if err == nil { + 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, "", "") + // TODO log error + if err == nil { + 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)) + } + + compiledHTML := strings.Join(htmlParts, "") + if embed.Color != 0 { + compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML) + } else { + compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML) + } + content := format.HTMLToContent(compiledHTML) + content.RelatesTo = threadRelation.Copy() + + resp, err := portal.sendMatrixMessage(intent, event.EventMessage, &content, nil, 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("%s-e%d", msgID, index+1), + MXID: resp.EventID, + } +} + func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Message, thread *Thread) { if portal.MXID == "" { portal.log.Warnln("handle message called without a valid portal") @@ -732,6 +867,12 @@ func (portal *Portal) handleDiscordMessageCreate(user *User, msg *discordgo.Mess parts = append(parts, *part) } } + for i, embed := range msg.Embeds { + part := portal.handleDiscordEmbed(intent, embed, msg.ID, i, ts, threadRelation) + if part != nil { + parts = append(parts, *part) + } + } if len(parts) == 0 { portal.log.Warnfln("Unhandled message %s (type %d)", msg.ID, msg.Type) } else {