Add support for text embeds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
12
formatter.go
12
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
go.mod
1
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
go.sum
7
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
portal.go
141
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"> <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"> <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 {
|
||||
|
||||
Reference in New Issue
Block a user