portal_convert.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. // mautrix-discord - A Matrix-Discord puppeting bridge.
  2. // Copyright (C) 2023 Tulir Asokan
  3. //
  4. // This program is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU Affero General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // This program is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU Affero General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU Affero General Public License
  15. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. package main
  17. import (
  18. "context"
  19. "fmt"
  20. "html"
  21. "strconv"
  22. "strings"
  23. "time"
  24. "github.com/bwmarrin/discordgo"
  25. "github.com/rs/zerolog"
  26. "golang.org/x/exp/slices"
  27. "maunium.net/go/mautrix/id"
  28. "maunium.net/go/mautrix"
  29. "maunium.net/go/mautrix/appservice"
  30. "maunium.net/go/mautrix/event"
  31. "maunium.net/go/mautrix/format"
  32. )
  33. type ConvertedMessage struct {
  34. AttachmentID string
  35. Type event.Type
  36. Content *event.MessageEventContent
  37. Extra map[string]any
  38. }
  39. func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
  40. return &event.MessageEventContent{
  41. Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
  42. MsgType: event.MsgNotice,
  43. }
  44. }
  45. const DiscordStickerSize = 160
  46. func (portal *Portal) convertDiscordFile(ctx context.Context, typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
  47. meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
  48. if typeName == "sticker" && content.Info.MimeType == "application/json" {
  49. meta.Converter = portal.bridge.convertLottie
  50. }
  51. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
  52. if err != nil {
  53. zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
  54. return portal.createMediaFailedMessage(err)
  55. }
  56. if typeName == "sticker" && content.Info.MimeType == "application/json" {
  57. content.Info.MimeType = dbFile.MimeType
  58. }
  59. content.Info.Size = dbFile.Size
  60. if content.Info.Width == 0 && content.Info.Height == 0 {
  61. content.Info.Width = dbFile.Width
  62. content.Info.Height = dbFile.Height
  63. }
  64. if dbFile.DecryptionInfo != nil {
  65. content.File = &event.EncryptedFileInfo{
  66. EncryptedFile: *dbFile.DecryptionInfo,
  67. URL: dbFile.MXC.CUString(),
  68. }
  69. } else {
  70. content.URL = dbFile.MXC.CUString()
  71. }
  72. return content
  73. }
  74. func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
  75. if content.Info == nil {
  76. return
  77. }
  78. if content.Info.Width == 0 && content.Info.Height == 0 {
  79. content.Info.Width = DiscordStickerSize
  80. content.Info.Height = DiscordStickerSize
  81. } else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
  82. if content.Info.Width > content.Info.Height {
  83. content.Info.Height /= content.Info.Width / DiscordStickerSize
  84. content.Info.Width = DiscordStickerSize
  85. } else if content.Info.Width < content.Info.Height {
  86. content.Info.Width /= content.Info.Height / DiscordStickerSize
  87. content.Info.Height = DiscordStickerSize
  88. } else {
  89. content.Info.Width = DiscordStickerSize
  90. content.Info.Height = DiscordStickerSize
  91. }
  92. }
  93. }
  94. func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
  95. var mime, ext string
  96. switch sticker.FormatType {
  97. case discordgo.StickerFormatTypePNG:
  98. mime = "image/png"
  99. ext = "png"
  100. case discordgo.StickerFormatTypeAPNG:
  101. mime = "image/apng"
  102. ext = "png"
  103. case discordgo.StickerFormatTypeLottie:
  104. mime = "application/json"
  105. ext = "json"
  106. case discordgo.StickerFormatTypeGIF:
  107. mime = "image/gif"
  108. ext = "gif"
  109. default:
  110. zerolog.Ctx(ctx).Warn().
  111. Int("sticker_format", int(sticker.FormatType)).
  112. Str("sticker_id", sticker.ID).
  113. Msg("Unknown sticker format")
  114. }
  115. content := &event.MessageEventContent{
  116. Body: sticker.Name, // TODO find description from somewhere?
  117. Info: &event.FileInfo{
  118. MimeType: mime,
  119. },
  120. }
  121. mxc := portal.bridge.Config.Bridge.MediaPatterns.Sticker(sticker.ID, ext)
  122. if mxc.IsEmpty() {
  123. content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
  124. } else {
  125. content.URL = mxc.CUString()
  126. }
  127. portal.cleanupConvertedStickerInfo(content)
  128. return &ConvertedMessage{
  129. AttachmentID: sticker.ID,
  130. Type: event.EventSticker,
  131. Content: content,
  132. }
  133. }
  134. func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
  135. content := &event.MessageEventContent{
  136. Body: att.Filename,
  137. Info: &event.FileInfo{
  138. Height: att.Height,
  139. MimeType: att.ContentType,
  140. Width: att.Width,
  141. // This gets overwritten later after the file is uploaded to the homeserver
  142. Size: att.Size,
  143. },
  144. }
  145. if att.Description != "" {
  146. content.Body = att.Description
  147. content.FileName = att.Filename
  148. }
  149. var extra map[string]any
  150. switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
  151. case "audio":
  152. content.MsgType = event.MsgAudio
  153. if att.Waveform != nil {
  154. // TODO convert waveform
  155. extra = map[string]any{
  156. "org.matrix.1767.audio": map[string]any{
  157. "duration": int(att.DurationSeconds * 1000),
  158. },
  159. "org.matrix.msc3245.voice": map[string]any{},
  160. }
  161. }
  162. case "image":
  163. content.MsgType = event.MsgImage
  164. case "video":
  165. content.MsgType = event.MsgVideo
  166. default:
  167. content.MsgType = event.MsgFile
  168. }
  169. mxc := portal.bridge.Config.Bridge.MediaPatterns.Attachment(portal.Key.ChannelID, att.ID, att.Filename)
  170. if mxc.IsEmpty() {
  171. content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
  172. } else {
  173. content.URL = mxc.CUString()
  174. }
  175. return &ConvertedMessage{
  176. AttachmentID: att.ID,
  177. Type: event.EventMessage,
  178. Content: content,
  179. Extra: extra,
  180. }
  181. }
  182. func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
  183. attachmentID := fmt.Sprintf("video_%s", embed.URL)
  184. var proxyURL string
  185. if embed.Video != nil {
  186. proxyURL = embed.Video.ProxyURL
  187. } else if embed.Thumbnail != nil {
  188. proxyURL = embed.Thumbnail.ProxyURL
  189. } else {
  190. zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed")
  191. return &ConvertedMessage{
  192. AttachmentID: attachmentID,
  193. Type: event.EventMessage,
  194. Content: &event.MessageEventContent{
  195. Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed",
  196. MsgType: event.MsgNotice,
  197. },
  198. }
  199. }
  200. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, proxyURL, portal.Encrypted, NoMeta)
  201. if err != nil {
  202. zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
  203. return &ConvertedMessage{
  204. AttachmentID: attachmentID,
  205. Type: event.EventMessage,
  206. Content: portal.createMediaFailedMessage(err),
  207. }
  208. }
  209. content := &event.MessageEventContent{
  210. Body: embed.URL,
  211. Info: &event.FileInfo{
  212. MimeType: dbFile.MimeType,
  213. Size: dbFile.Size,
  214. },
  215. }
  216. if embed.Video != nil {
  217. content.MsgType = event.MsgVideo
  218. content.Info.Width = embed.Video.Width
  219. content.Info.Height = embed.Video.Height
  220. } else {
  221. content.MsgType = event.MsgImage
  222. content.Info.Width = embed.Thumbnail.Width
  223. content.Info.Height = embed.Thumbnail.Height
  224. }
  225. if content.Info.Width == 0 && content.Info.Height == 0 {
  226. content.Info.Width = dbFile.Width
  227. content.Info.Height = dbFile.Height
  228. }
  229. if dbFile.DecryptionInfo != nil {
  230. content.File = &event.EncryptedFileInfo{
  231. EncryptedFile: *dbFile.DecryptionInfo,
  232. URL: dbFile.MXC.CUString(),
  233. }
  234. } else {
  235. content.URL = dbFile.MXC.CUString()
  236. }
  237. extra := map[string]any{}
  238. if content.MsgType == event.MsgVideo && embed.Type == discordgo.EmbedTypeGifv {
  239. extra["info"] = map[string]any{
  240. "fi.mau.discord.gifv": true,
  241. "fi.mau.loop": true,
  242. "fi.mau.autoplay": true,
  243. "fi.mau.hide_controls": true,
  244. "fi.mau.no_audio": true,
  245. }
  246. }
  247. return &ConvertedMessage{
  248. AttachmentID: attachmentID,
  249. Type: event.EventMessage,
  250. Content: content,
  251. Extra: extra,
  252. }
  253. }
  254. func (portal *Portal) convertDiscordMessage(ctx context.Context, puppet *Puppet, intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
  255. predictedLength := len(msg.Attachments) + len(msg.StickerItems)
  256. if msg.Content != "" {
  257. predictedLength++
  258. }
  259. parts := make([]*ConvertedMessage, 0, predictedLength)
  260. if textPart := portal.convertDiscordTextMessage(ctx, intent, msg); textPart != nil {
  261. parts = append(parts, textPart)
  262. }
  263. log := zerolog.Ctx(ctx)
  264. handledIDs := make(map[string]struct{})
  265. for _, att := range msg.Attachments {
  266. if _, handled := handledIDs[att.ID]; handled {
  267. continue
  268. }
  269. handledIDs[att.ID] = struct{}{}
  270. log := log.With().Str("attachment_id", att.ID).Logger()
  271. if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
  272. parts = append(parts, part)
  273. }
  274. }
  275. for _, sticker := range msg.StickerItems {
  276. if _, handled := handledIDs[sticker.ID]; handled {
  277. continue
  278. }
  279. handledIDs[sticker.ID] = struct{}{}
  280. log := log.With().Str("sticker_id", sticker.ID).Logger()
  281. if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
  282. parts = append(parts, part)
  283. }
  284. }
  285. for i, embed := range msg.Embeds {
  286. // Ignore non-video embeds, they're handled in convertDiscordTextMessage
  287. if getEmbedType(msg, embed) != EmbedVideo {
  288. continue
  289. }
  290. // Discord deduplicates embeds by URL. It makes things easier for us too.
  291. if _, handled := handledIDs[embed.URL]; handled {
  292. continue
  293. }
  294. handledIDs[embed.URL] = struct{}{}
  295. log := log.With().
  296. Str("computed_embed_type", "video").
  297. Str("embed_type", string(embed.Type)).
  298. Int("embed_index", i).
  299. Logger()
  300. part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
  301. if part != nil {
  302. parts = append(parts, part)
  303. }
  304. }
  305. if len(parts) == 0 && msg.Thread != nil {
  306. parts = append(parts, &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
  307. MsgType: event.MsgText,
  308. Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
  309. }})
  310. }
  311. for _, part := range parts {
  312. puppet.addWebhookMeta(part, msg)
  313. puppet.addMemberMeta(part, msg)
  314. }
  315. return parts
  316. }
  317. func (puppet *Puppet) addMemberMeta(part *ConvertedMessage, msg *discordgo.Message) {
  318. if msg.Member == nil {
  319. return
  320. }
  321. if part.Extra == nil {
  322. part.Extra = make(map[string]any)
  323. }
  324. var avatarURL id.ContentURI
  325. var discordAvatarURL string
  326. if msg.Member.Avatar != "" {
  327. var err error
  328. avatarURL, discordAvatarURL, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), msg.GuildID, msg.Author.ID, msg.Author.Avatar)
  329. if err != nil {
  330. puppet.log.Warn().Err(err).
  331. Str("avatar_id", msg.Author.Avatar).
  332. Msg("Failed to reupload guild user avatar")
  333. }
  334. }
  335. part.Extra["fi.mau.discord.guild_member_metadata"] = map[string]any{
  336. "nick": msg.Member.Nick,
  337. "avatar_id": msg.Member.Avatar,
  338. "avatar_url": discordAvatarURL,
  339. "avatar_mxc": avatarURL.String(),
  340. }
  341. if msg.Member.Nick != "" || !avatarURL.IsEmpty() {
  342. perMessageProfile := map[string]any{
  343. "is_multiple_users": false,
  344. "displayname": msg.Member.Nick,
  345. "avatar_url": avatarURL.String(),
  346. }
  347. if msg.Member.Nick == "" {
  348. perMessageProfile["displayname"] = puppet.Name
  349. }
  350. if avatarURL.IsEmpty() {
  351. perMessageProfile["avatar_url"] = puppet.AvatarURL.String()
  352. }
  353. part.Extra["com.beeper.per_message_profile"] = perMessageProfile
  354. }
  355. }
  356. func (puppet *Puppet) addWebhookMeta(part *ConvertedMessage, msg *discordgo.Message) {
  357. if msg.WebhookID == "" {
  358. return
  359. }
  360. if part.Extra == nil {
  361. part.Extra = make(map[string]any)
  362. }
  363. var avatarURL id.ContentURI
  364. if msg.Author.Avatar != "" {
  365. var err error
  366. avatarURL, _, err = puppet.bridge.reuploadUserAvatar(puppet.DefaultIntent(), "", msg.Author.ID, msg.Author.Avatar)
  367. if err != nil {
  368. puppet.log.Warn().Err(err).
  369. Str("avatar_id", msg.Author.Avatar).
  370. Msg("Failed to reupload webhook avatar")
  371. }
  372. }
  373. part.Extra["fi.mau.discord.webhook_metadata"] = map[string]any{
  374. "id": msg.WebhookID,
  375. "name": msg.Author.Username,
  376. "avatar_id": msg.Author.Avatar,
  377. "avatar_url": msg.Author.AvatarURL(""),
  378. "avatar_mxc": avatarURL.String(),
  379. }
  380. part.Extra["com.beeper.per_message_profile"] = map[string]any{
  381. "is_multiple_users": true,
  382. "avatar_url": avatarURL.String(),
  383. "displayname": msg.Author.Username,
  384. }
  385. }
  386. const (
  387. embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
  388. embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
  389. embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>`
  390. embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
  391. embedHTMLAuthorLink = `<a href="%s">%s</a>`
  392. embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
  393. embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
  394. embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
  395. embedHTMLFieldName = `<th>%s</th>`
  396. embedHTMLFieldValue = `<td>%s</td>`
  397. embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
  398. embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
  399. embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
  400. embedHTMLFooterWithImage = `<p class="discord-embed-footer"><sub><img data-mx-emoticon height="20" src="%s" title="Footer icon" alt="">&nbsp;<span>%s</span>%s</sub></p>`
  401. embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
  402. embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
  403. embedHTMLDate = `<time datetime="%s">%s</time>`
  404. embedFooterDateSeparator = ` • `
  405. )
  406. func (portal *Portal) convertDiscordRichEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
  407. log := zerolog.Ctx(ctx)
  408. var htmlParts []string
  409. if embed.Author != nil {
  410. var authorHTML string
  411. authorNameHTML := html.EscapeString(embed.Author.Name)
  412. if embed.Author.URL != "" {
  413. authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
  414. }
  415. authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
  416. if embed.Author.ProxyIconURL != "" {
  417. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
  418. if err != nil {
  419. log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
  420. } else {
  421. authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
  422. }
  423. }
  424. htmlParts = append(htmlParts, authorHTML)
  425. }
  426. if embed.Title != "" {
  427. var titleHTML string
  428. baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
  429. if embed.URL != "" {
  430. titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
  431. } else {
  432. titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
  433. }
  434. htmlParts = append(htmlParts, titleHTML)
  435. }
  436. if embed.Description != "" {
  437. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
  438. }
  439. for i := 0; i < len(embed.Fields); i++ {
  440. item := embed.Fields[i]
  441. if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
  442. splitItems := []*discordgo.MessageEmbedField{item}
  443. if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
  444. splitItems = append(splitItems, embed.Fields[i+1])
  445. i++
  446. if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
  447. splitItems = append(splitItems, embed.Fields[i+1])
  448. i++
  449. }
  450. }
  451. headerParts := make([]string, len(splitItems))
  452. contentParts := make([]string, len(splitItems))
  453. for j, splitItem := range splitItems {
  454. headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
  455. contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
  456. }
  457. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
  458. } else {
  459. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
  460. strconv.FormatBool(item.Inline),
  461. portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
  462. portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
  463. ))
  464. }
  465. }
  466. if embed.Image != nil {
  467. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
  468. if err != nil {
  469. log.Warn().Err(err).Msg("Failed to reupload image in embed")
  470. } else {
  471. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
  472. }
  473. }
  474. var embedDateHTML string
  475. if embed.Timestamp != "" {
  476. formattedTime := embed.Timestamp
  477. parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
  478. if err != nil {
  479. log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
  480. } else {
  481. formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
  482. }
  483. embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
  484. }
  485. if embed.Footer != nil {
  486. var footerHTML string
  487. var datePart string
  488. if embedDateHTML != "" {
  489. datePart = embedFooterDateSeparator + embedDateHTML
  490. }
  491. footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
  492. if embed.Footer.ProxyIconURL != "" {
  493. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
  494. if err != nil {
  495. log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
  496. } else {
  497. footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
  498. }
  499. }
  500. htmlParts = append(htmlParts, footerHTML)
  501. } else if embed.Timestamp != "" {
  502. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
  503. }
  504. if len(htmlParts) == 0 {
  505. return ""
  506. }
  507. compiledHTML := strings.Join(htmlParts, "")
  508. if embed.Color != 0 {
  509. compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
  510. } else {
  511. compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
  512. }
  513. return compiledHTML
  514. }
  515. type BeeperLinkPreview struct {
  516. mautrix.RespPreviewURL
  517. MatchedURL string `json:"matched_url"`
  518. ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
  519. }
  520. func (portal *Portal) convertDiscordLinkEmbedImage(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
  521. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
  522. if err != nil {
  523. zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
  524. return
  525. }
  526. if width != 0 || height != 0 {
  527. preview.ImageWidth = width
  528. preview.ImageHeight = height
  529. } else {
  530. preview.ImageWidth = dbFile.Width
  531. preview.ImageHeight = dbFile.Height
  532. }
  533. preview.ImageSize = dbFile.Size
  534. preview.ImageType = dbFile.MimeType
  535. if dbFile.Encrypted {
  536. preview.ImageEncryption = &event.EncryptedFileInfo{
  537. EncryptedFile: *dbFile.DecryptionInfo,
  538. URL: dbFile.MXC.CUString(),
  539. }
  540. } else {
  541. preview.ImageURL = dbFile.MXC.CUString()
  542. }
  543. }
  544. func (portal *Portal) convertDiscordLinkEmbedToBeeper(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
  545. var preview BeeperLinkPreview
  546. preview.MatchedURL = embed.URL
  547. preview.Title = embed.Title
  548. preview.Description = embed.Description
  549. if embed.Image != nil {
  550. portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
  551. } else if embed.Thumbnail != nil {
  552. portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
  553. }
  554. return &preview
  555. }
  556. const msgInteractionTemplateHTML = `<blockquote>
  557. <a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
  558. </blockquote>`
  559. const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
  560. type BridgeEmbedType int
  561. const (
  562. EmbedUnknown BridgeEmbedType = iota
  563. EmbedRich
  564. EmbedLinkPreview
  565. EmbedVideo
  566. )
  567. func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
  568. // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
  569. // so this is a hacky way to detect those.
  570. return embed.Video != nil && embed.Video.ProxyURL == ""
  571. }
  572. func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
  573. switch embed.Type {
  574. case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
  575. return EmbedLinkPreview
  576. case discordgo.EmbedTypeVideo:
  577. if isActuallyLinkPreview(embed) {
  578. return EmbedLinkPreview
  579. }
  580. return EmbedVideo
  581. case discordgo.EmbedTypeGifv:
  582. return EmbedVideo
  583. case discordgo.EmbedTypeImage:
  584. if msg != nil && isPlainGifMessage(msg) {
  585. return EmbedVideo
  586. } else if embed.Image == nil && embed.Thumbnail != nil {
  587. return EmbedLinkPreview
  588. }
  589. return EmbedRich
  590. case discordgo.EmbedTypeRich:
  591. return EmbedRich
  592. default:
  593. return EmbedUnknown
  594. }
  595. }
  596. func isPlainGifMessage(msg *discordgo.Message) bool {
  597. if len(msg.Embeds) != 1 {
  598. return false
  599. }
  600. embed := msg.Embeds[0]
  601. isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
  602. isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil
  603. contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
  604. return contentIsOnlyURL && (isGifVideo || isGifImage)
  605. }
  606. func (portal *Portal) convertDiscordMentions(msg *discordgo.Message, syncGhosts bool) *event.Mentions {
  607. var matrixMentions event.Mentions
  608. for _, mention := range msg.Mentions {
  609. puppet := portal.bridge.GetPuppetByID(mention.ID)
  610. if syncGhosts {
  611. puppet.UpdateInfo(nil, mention, nil)
  612. }
  613. user := portal.bridge.GetUserByID(mention.ID)
  614. if user != nil {
  615. matrixMentions.UserIDs = append(matrixMentions.UserIDs, user.MXID)
  616. } else {
  617. matrixMentions.UserIDs = append(matrixMentions.UserIDs, puppet.MXID)
  618. }
  619. }
  620. slices.Sort(matrixMentions.UserIDs)
  621. matrixMentions.UserIDs = slices.Compact(matrixMentions.UserIDs)
  622. if msg.MentionEveryone {
  623. matrixMentions.Room = true
  624. }
  625. return &matrixMentions
  626. }
  627. func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
  628. log := zerolog.Ctx(ctx)
  629. if msg.Type == discordgo.MessageTypeCall {
  630. return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
  631. MsgType: event.MsgEmote,
  632. Body: "started a call",
  633. }}
  634. } else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
  635. return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
  636. MsgType: event.MsgEmote,
  637. Body: "joined the server",
  638. }}
  639. }
  640. var htmlParts []string
  641. if msg.Interaction != nil {
  642. puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
  643. puppet.UpdateInfo(nil, msg.Interaction.User, nil)
  644. htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
  645. }
  646. if msg.Content != "" && !isPlainGifMessage(msg) {
  647. htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, true))
  648. }
  649. previews := make([]*BeeperLinkPreview, 0)
  650. for i, embed := range msg.Embeds {
  651. if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
  652. continue
  653. }
  654. with := log.With().
  655. Str("embed_type", string(embed.Type)).
  656. Int("embed_index", i)
  657. switch getEmbedType(msg, embed) {
  658. case EmbedRich:
  659. log := with.Str("computed_embed_type", "rich").Logger()
  660. htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
  661. case EmbedLinkPreview:
  662. log := with.Str("computed_embed_type", "link preview").Logger()
  663. previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
  664. case EmbedVideo:
  665. // Ignore video embeds, they're handled as separate messages
  666. default:
  667. log := with.Logger()
  668. log.Warn().Msg("Unknown embed type in message")
  669. }
  670. }
  671. if len(msg.Components) > 0 {
  672. htmlParts = append(htmlParts, msgComponentTemplateHTML)
  673. }
  674. if len(htmlParts) == 0 {
  675. return nil
  676. }
  677. fullHTML := strings.Join(htmlParts, "\n")
  678. if !msg.MentionEveryone {
  679. fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
  680. }
  681. content := format.HTMLToContent(fullHTML)
  682. extraContent := map[string]any{
  683. "com.beeper.linkpreviews": previews,
  684. }
  685. if msg.WebhookID != "" && msg.ApplicationID == "" && portal.bridge.Config.Bridge.PrefixWebhookMessages {
  686. content.EnsureHasHTML()
  687. content.Body = fmt.Sprintf("%s: %s", msg.Author.Username, content.Body)
  688. content.FormattedBody = fmt.Sprintf("<strong>%s</strong>: %s", html.EscapeString(msg.Author.Username), content.FormattedBody)
  689. }
  690. return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
  691. }