forked from mirror/mautrix-discord
Refactor message handling to fully use convert pattern
This commit is contained in:
parent
a0cc5ec9bc
commit
7b5c057dcf
2 changed files with 556 additions and 561 deletions
598
portal.go
598
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 = `<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 height="24" src="%s" title="Author icon" alt=""> <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>`
|
||||
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
|
||||
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt=""> <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) 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 = `<blockquote>
|
||||
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
|
||||
</blockquote>`
|
||||
|
||||
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
|
||||
|
||||
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)
|
||||
|
|
519
portal_convert.go
Normal file
519
portal_convert.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 = `<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 height="24" src="%s" title="Author icon" alt=""> <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>`
|
||||
embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
|
||||
embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt=""> <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) 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 = `<blockquote>
|
||||
<a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
|
||||
</blockquote>`
|
||||
|
||||
const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
|
||||
|
||||
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}
|
||||
}
|
Loading…
Reference in a new issue