Browse Source

Add support for text embeds

Tulir Asokan 2 years ago
parent
commit
e33bc8c462
8 changed files with 169 additions and 1 deletions
  1. 4 0
      attachments.go
  2. 1 0
      config/bridge.go
  3. 1 0
      config/upgrade.go
  4. 3 0
      example-config.yaml
  5. 11 1
      formatter.go
  6. 1 0
      go.mod
  7. 7 0
      go.sum
  8. 141 0
      portal.go

+ 4 - 0
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

+ 1 - 0
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"`

+ 1 - 0
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")

+ 3 - 0
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

+ 11 - 1
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"

+ 1 - 0
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

+ 7 - 0
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=

+ 141 - 0
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         = `<blockquote class="discord-embed">%s</blockquote>`
+	embedHTMLWrapperColor    = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
+	embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon width="24" height="24" src="%s" title="Author icon" alt="Author icon">&nbsp;<span>%s</span></p>`
+	embedHTMLAuthorPlain     = `<p class="discord-embed-author"><span>%s</span></p>`
+	embedHTMLAuthorLink      = `<a href="%s">%s</a>`
+	embedHTMLTitleWithLink   = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
+	embedHTMLTitlePlain      = `<p class="discord-embed-title"><strong>%s</strong></p>`
+	embedHTMLDescription     = `<p class="discord-embed-description">%s</p>`
+	embedHTMLFieldName       = `<th>%s</th>`
+	embedHTMLFieldValue      = `<td>%s</td>`
+	embedHTMLFields          = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
+	embedHTMLLinearField     = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
+	embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon width="20" height="20" src="%s" title="Footer icon" alt="Footer icon">&nbsp;<span>%s</span>%s</sub></p>`
+	embedHTMLFooterPlain     = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
+	embedHTMLFooterOnlyDate  = `<p class="discord-embed-footer"><sub>%s</sub></p>`
+	embedHTMLDate            = `<time datetime="%s">%s</time>`
+	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 {