123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721 |
- // 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 (
- "context"
- "fmt"
- "html"
- "strconv"
- "strings"
- "time"
- "github.com/bwmarrin/discordgo"
- "github.com/rs/zerolog"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix/id"
- "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(ctx context.Context, 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 {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
- 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 dbFile.DecryptionInfo != nil {
- content.File = &event.EncryptedFileInfo{
- EncryptedFile: *dbFile.DecryptionInfo,
- URL: dbFile.MXC.CUString(),
- }
- } else {
- content.URL = dbFile.MXC.CUString()
- }
- return content
- }
- func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
- if content.Info == nil {
- return
- }
- if content.Info.Width == 0 && content.Info.Height == 0 {
- content.Info.Width = DiscordStickerSize
- content.Info.Height = DiscordStickerSize
- } else if 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
- }
- }
- }
- func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
- var mime, ext string
- switch sticker.FormatType {
- case discordgo.StickerFormatTypePNG:
- mime = "image/png"
- ext = "png"
- case discordgo.StickerFormatTypeAPNG:
- mime = "image/apng"
- ext = "png"
- case discordgo.StickerFormatTypeLottie:
- mime = "application/json"
- ext = "json"
- case discordgo.StickerFormatTypeGIF:
- mime = "image/gif"
- ext = "gif"
- default:
- zerolog.Ctx(ctx).Warn().
- Int("sticker_format", int(sticker.FormatType)).
- Str("sticker_id", sticker.ID).
- Msg("Unknown sticker format")
- }
- content := &event.MessageEventContent{
- Body: sticker.Name, // TODO find description from somewhere?
- Info: &event.FileInfo{
- MimeType: mime,
- },
- }
- mxc := portal.bridge.Config.Bridge.MediaPatterns.Sticker(sticker.ID, ext)
- if mxc.IsEmpty() {
- content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
- } else {
- content.URL = mxc.CUString()
- }
- portal.cleanupConvertedStickerInfo(content)
- return &ConvertedMessage{
- AttachmentID: sticker.ID,
- Type: event.EventSticker,
- Content: content,
- }
- }
- func (portal *Portal) convertDiscordAttachment(ctx context.Context, 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
- }
- var extra map[string]any
- switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
- case "audio":
- content.MsgType = event.MsgAudio
- if att.Waveform != nil {
- // TODO convert waveform
- extra = map[string]any{
- "org.matrix.1767.audio": map[string]any{
- "duration": int(att.DurationSeconds * 1000),
- },
- "org.matrix.msc3245.voice": map[string]any{},
- }
- }
- case "image":
- content.MsgType = event.MsgImage
- case "video":
- content.MsgType = event.MsgVideo
- default:
- content.MsgType = event.MsgFile
- }
- mxc := portal.bridge.Config.Bridge.MediaPatterns.Attachment(portal.Key.ChannelID, att.ID, att.Filename)
- if mxc.IsEmpty() {
- content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
- } else {
- content.URL = mxc.CUString()
- }
- return &ConvertedMessage{
- AttachmentID: att.ID,
- Type: event.EventMessage,
- Content: content,
- Extra: extra,
- }
- }
- func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
- attachmentID := fmt.Sprintf("video_%s", embed.URL)
- var proxyURL string
- if embed.Video != nil {
- proxyURL = embed.Video.ProxyURL
- } else {
- proxyURL = embed.Thumbnail.ProxyURL
- }
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
- return &ConvertedMessage{
- AttachmentID: attachmentID,
- Type: event.EventMessage,
- Content: portal.createMediaFailedMessage(err),
- }
- }
- content := &event.MessageEventContent{
- Body: embed.URL,
- Info: &event.FileInfo{
- MimeType: dbFile.MimeType,
- Size: dbFile.Size,
- },
- }
- if embed.Video != nil {
- content.MsgType = event.MsgVideo
- content.Info.Width = embed.Video.Width
- content.Info.Height = embed.Video.Height
- } else {
- content.MsgType = event.MsgImage
- content.Info.Width = embed.Thumbnail.Width
- content.Info.Height = embed.Thumbnail.Height
- }
- 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 content.MsgType == event.MsgVideo && 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(ctx context.Context, puppet *Puppet, 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(ctx, intent, msg); textPart != nil {
- parts = append(parts, textPart)
- }
- log := zerolog.Ctx(ctx)
- handledIDs := make(map[string]struct{})
- for _, att := range msg.Attachments {
- if _, handled := handledIDs[att.ID]; handled {
- continue
- }
- handledIDs[att.ID] = struct{}{}
- log := log.With().Str("attachment_id", att.ID).Logger()
- if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
- parts = append(parts, part)
- }
- }
- for _, sticker := range msg.StickerItems {
- if _, handled := handledIDs[sticker.ID]; handled {
- continue
- }
- handledIDs[sticker.ID] = struct{}{}
- log := log.With().Str("sticker_id", sticker.ID).Logger()
- if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
- parts = append(parts, part)
- }
- }
- for i, embed := range msg.Embeds {
- // Ignore non-video embeds, they're handled in convertDiscordTextMessage
- if getEmbedType(msg, embed) != EmbedVideo {
- continue
- }
- // Discord deduplicates embeds by URL. It makes things easier for us too.
- if _, handled := handledIDs[embed.URL]; handled {
- continue
- }
- handledIDs[embed.URL] = struct{}{}
- log := log.With().
- Str("computed_embed_type", "video").
- Str("embed_type", string(embed.Type)).
- Int("embed_index", i).
- Logger()
- part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
- if part != nil {
- parts = append(parts, part)
- }
- }
- if len(parts) == 0 && msg.Thread != nil {
- parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
- }})
- }
- for _, part := range parts {
- puppet.addWebhookMeta(part, msg)
- puppet.addMemberMeta(part, msg)
- }
- return parts
- }
- func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
- if msg.Member == nil {
- return
- }
- if part.Extra == nil {
- part.Extra = make(map[string]any)
- }
- var avatarURL id.ContentURI
- var discordAvatarURL string
- if msg.Member.Avatar != "" {
- var err error
- avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
- if err != nil {
- puppet.log.Warn().Err(err).
- Str("avatar_id", msg.Author.Avatar).
- Msg("Failed to reupload guild user avatar")
- }
- }
- part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
- "nick": msg.Member.Nick,
- "avatar_id": msg.Member.Avatar,
- "avatar_url": discordAvatarURL,
- "avatar_mxc": avatarURL.String(),
- }
- if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
- perMessageProfile := map[string]any{
- "is_multiple_users": false,
- "displayname": msg.Member.Nick,
- "avatar_url": avatarURL.String(),
- }
- if msg.Member.Nick == "" {
- perMessageProfile["displayname"] = puppet.Name
- }
- if avatarURL.IsEmpty() {
- perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
- }
- part.Extra["com.beeper.per_message_profile"] = perMessageProfile
- }
- }
- func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
- if msg.WebhookID == "" {
- return
- }
- if part.Extra == nil {
- part.Extra = make(map[string]any)
- }
- var avatarURL id.ContentURI
- if msg.Author.Avatar != "" {
- var err error
- avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
- if err != nil {
- puppet.log.Warn().Err(err).
- Str("avatar_id", msg.Author.Avatar).
- Msg("Failed to reupload webhook avatar")
- }
- }
- part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
- "id": msg.WebhookID,
- "name": msg.Author.Username,
- "avatar_id": msg.Author.Avatar,
- "avatar_url": msg.Author.AvatarURL(""),
- "avatar_mxc": avatarURL.String(),
- }
- part.Extra["com.beeper.per_message_profile"] = map[string]any{
- "is_multiple_users": true,
- "avatar_url": avatarURL.String(),
- "displayname": msg.Author.Username,
- }
- }
- 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(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
- log := zerolog.Ctx(ctx)
- 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 {
- log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
- } 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 {
- log.Warn().Err(err).Msg("Failed to reupload image in embed")
- } 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 {
- log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
- } 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 {
- log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
- } 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(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
- dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
- return
- }
- 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(ctx context.Context, 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(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
- } else if embed.Thumbnail != nil {
- portal.convertDiscordLinkEmbedImage(ctx, 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(msg *discordgo.Message, 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.EmbedTypeImage:
- if msg != nil && isPlainGifMessage(msg) {
- return EmbedVideo
- } else if embed.Image == nil && embed.Thumbnail != nil {
- return EmbedLinkPreview
- }
- return EmbedRich
- case discordgo.EmbedTypeRich:
- return EmbedRich
- default:
- return EmbedUnknown
- }
- }
- func isPlainGifMessage(msg *discordgo.Message) bool {
- return len(msg.Embeds) == 1 && msg.Embeds[0].URL == msg.Content &&
- ((msg.Embeds[0].Type == discordgo.EmbedTypeGifv && msg.Embeds[0].Video != nil) ||
- (msg.Embeds[0].Type == discordgo.EmbedTypeImage && msg.Embeds[0].Image == nil && msg.Embeds[0].Thumbnail != nil))
- }
- func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
- var matrixMentions event.Mentions
- for _, mention := range msg.Mentions {
- puppet := portal.bridge.GetPuppetByID(mention.ID)
- if syncGhosts {
- puppet.UpdateInfo(nil, mention, nil)
- }
- user := portal.bridge.GetUserByID(mention.ID)
- if user != nil {
- matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
- } else {
- matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
- }
- }
- slices.Sort(matrixMentions.UserIDs)
- matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
- if msg.MentionEveryone {
- matrixMentions.Room = true
- }
- return &matrixMentions
- }
- func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
- log := zerolog.Ctx(ctx)
- 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, nil)
- 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 {
- if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
- continue
- }
- with := log.With().
- Str("embed_type", string(embed.Type)).
- Int("embed_index", i)
- switch getEmbedType(msg, embed) {
- case EmbedRich:
- log := with.Str("computed_embed_type", "rich").Logger()
- htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
- case EmbedLinkPreview:
- log := with.Str("computed_embed_type", "link preview").Logger()
- previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
- case EmbedVideo:
- // Ignore video embeds, they're handled as separate messages
- default:
- log := with.Logger()
- log.Warn().Msg("Unknown embed type in message")
- }
- }
- 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,
- }
- if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
- content.EnsureHasHTML()
- content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
- content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
- }
- return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
- }
|