portal_convert.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  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. "maunium.net/go/mautrix"
  27. "maunium.net/go/mautrix/appservice"
  28. "maunium.net/go/mautrix/event"
  29. "maunium.net/go/mautrix/format"
  30. )
  31. type ConvertedMessage struct {
  32. AttachmentID string
  33. Type event.Type
  34. Content *event.MessageEventContent
  35. Extra map[string]any
  36. }
  37. func (portal *Portal) createMediaFailedMessage(bridgeErr error) *event.MessageEventContent {
  38. return &event.MessageEventContent{
  39. Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr),
  40. MsgType: event.MsgNotice,
  41. }
  42. }
  43. const DiscordStickerSize = 160
  44. func (portal *Portal) convertDiscordFile(ctx context.Context, typeName string, intent *appservice.IntentAPI, id, url string, content *event.MessageEventContent) *event.MessageEventContent {
  45. meta := AttachmentMeta{AttachmentID: id, MimeType: content.Info.MimeType}
  46. if typeName == "sticker" && content.Info.MimeType == "application/json" {
  47. meta.Converter = portal.bridge.convertLottie
  48. }
  49. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, meta)
  50. if err != nil {
  51. zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix")
  52. return portal.createMediaFailedMessage(err)
  53. }
  54. if typeName == "sticker" && content.Info.MimeType == "application/json" {
  55. content.Info.MimeType = dbFile.MimeType
  56. }
  57. content.Info.Size = dbFile.Size
  58. if content.Info.Width == 0 && content.Info.Height == 0 {
  59. content.Info.Width = dbFile.Width
  60. content.Info.Height = dbFile.Height
  61. }
  62. if dbFile.DecryptionInfo != nil {
  63. content.File = &event.EncryptedFileInfo{
  64. EncryptedFile: *dbFile.DecryptionInfo,
  65. URL: dbFile.MXC.CUString(),
  66. }
  67. } else {
  68. content.URL = dbFile.MXC.CUString()
  69. }
  70. return content
  71. }
  72. func (portal *Portal) cleanupConvertedStickerInfo(content *event.MessageEventContent) {
  73. if content.Info.Width == 0 && content.Info.Height == 0 {
  74. content.Info.Width = DiscordStickerSize
  75. content.Info.Height = DiscordStickerSize
  76. } else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
  77. if content.Info.Width > content.Info.Height {
  78. content.Info.Height /= content.Info.Width / DiscordStickerSize
  79. content.Info.Width = DiscordStickerSize
  80. } else if content.Info.Width < content.Info.Height {
  81. content.Info.Width /= content.Info.Height / DiscordStickerSize
  82. content.Info.Height = DiscordStickerSize
  83. } else {
  84. content.Info.Width = DiscordStickerSize
  85. content.Info.Height = DiscordStickerSize
  86. }
  87. }
  88. }
  89. func (portal *Portal) convertDiscordSticker(ctx context.Context, intent *appservice.IntentAPI, sticker *discordgo.Sticker) *ConvertedMessage {
  90. var mime, ext string
  91. switch sticker.FormatType {
  92. case discordgo.StickerFormatTypePNG:
  93. mime = "image/png"
  94. ext = "png"
  95. case discordgo.StickerFormatTypeAPNG:
  96. mime = "image/apng"
  97. ext = "png"
  98. case discordgo.StickerFormatTypeLottie:
  99. mime = "application/json"
  100. ext = "json"
  101. case discordgo.StickerFormatTypeGIF:
  102. mime = "image/gif"
  103. ext = "gif"
  104. default:
  105. zerolog.Ctx(ctx).Warn().
  106. Int("sticker_format", int(sticker.FormatType)).
  107. Str("sticker_id", sticker.ID).
  108. Msg("Unknown sticker format")
  109. }
  110. content := &event.MessageEventContent{
  111. Body: sticker.Name, // TODO find description from somewhere?
  112. Info: &event.FileInfo{
  113. MimeType: mime,
  114. },
  115. }
  116. mxc := portal.bridge.Config.Bridge.MediaPatterns.Sticker(sticker.ID, ext)
  117. if mxc.IsEmpty() {
  118. content = portal.convertDiscordFile(ctx, "sticker", intent, sticker.ID, sticker.URL(), content)
  119. } else {
  120. content.URL = mxc.CUString()
  121. }
  122. portal.cleanupConvertedStickerInfo(content)
  123. return &ConvertedMessage{
  124. AttachmentID: sticker.ID,
  125. Type: event.EventSticker,
  126. Content: content,
  127. }
  128. }
  129. func (portal *Portal) convertDiscordAttachment(ctx context.Context, intent *appservice.IntentAPI, att *discordgo.MessageAttachment) *ConvertedMessage {
  130. content := &event.MessageEventContent{
  131. Body: att.Filename,
  132. Info: &event.FileInfo{
  133. Height: att.Height,
  134. MimeType: att.ContentType,
  135. Width: att.Width,
  136. // This gets overwritten later after the file is uploaded to the homeserver
  137. Size: att.Size,
  138. },
  139. }
  140. if att.Description != "" {
  141. content.Body = att.Description
  142. content.FileName = att.Filename
  143. }
  144. var extra map[string]any
  145. switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) {
  146. case "audio":
  147. content.MsgType = event.MsgAudio
  148. if att.Waveform != nil {
  149. // TODO convert waveform
  150. extra = map[string]any{
  151. "org.matrix.1767.audio": map[string]any{
  152. "duration": int(att.DurationSeconds * 1000),
  153. },
  154. "org.matrix.msc3245.voice": map[string]any{},
  155. }
  156. }
  157. case "image":
  158. content.MsgType = event.MsgImage
  159. case "video":
  160. content.MsgType = event.MsgVideo
  161. default:
  162. content.MsgType = event.MsgFile
  163. }
  164. mxc := portal.bridge.Config.Bridge.MediaPatterns.Attachment(portal.Key.ChannelID, att.ID, att.Filename)
  165. if mxc.IsEmpty() {
  166. content = portal.convertDiscordFile(ctx, "attachment", intent, att.ID, att.URL, content)
  167. } else {
  168. content.URL = mxc.CUString()
  169. }
  170. return &ConvertedMessage{
  171. AttachmentID: att.ID,
  172. Type: event.EventMessage,
  173. Content: content,
  174. Extra: extra,
  175. }
  176. }
  177. func (portal *Portal) convertDiscordVideoEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *ConvertedMessage {
  178. attachmentID := fmt.Sprintf("video_%s", embed.URL)
  179. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Video.ProxyURL, portal.Encrypted, NoMeta)
  180. if err != nil {
  181. zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
  182. return &ConvertedMessage{
  183. AttachmentID: attachmentID,
  184. Type: event.EventMessage,
  185. Content: portal.createMediaFailedMessage(err),
  186. }
  187. }
  188. content := &event.MessageEventContent{
  189. MsgType: event.MsgVideo,
  190. Body: embed.URL,
  191. Info: &event.FileInfo{
  192. Width: embed.Video.Width,
  193. Height: embed.Video.Height,
  194. MimeType: dbFile.MimeType,
  195. Size: dbFile.Size,
  196. },
  197. }
  198. if content.Info.Width == 0 && content.Info.Height == 0 {
  199. content.Info.Width = dbFile.Width
  200. content.Info.Height = dbFile.Height
  201. }
  202. if dbFile.DecryptionInfo != nil {
  203. content.File = &event.EncryptedFileInfo{
  204. EncryptedFile: *dbFile.DecryptionInfo,
  205. URL: dbFile.MXC.CUString(),
  206. }
  207. } else {
  208. content.URL = dbFile.MXC.CUString()
  209. }
  210. extra := map[string]any{}
  211. if embed.Type == discordgo.EmbedTypeGifv {
  212. extra["info"] = map[string]any{
  213. "fi.mau.discord.gifv": true,
  214. "fi.mau.loop": true,
  215. "fi.mau.autoplay": true,
  216. "fi.mau.hide_controls": true,
  217. "fi.mau.no_audio": true,
  218. }
  219. }
  220. return &ConvertedMessage{
  221. AttachmentID: attachmentID,
  222. Type: event.EventMessage,
  223. Content: content,
  224. Extra: extra,
  225. }
  226. }
  227. func (portal *Portal) convertDiscordMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) []*ConvertedMessage {
  228. predictedLength := len(msg.Attachments) + len(msg.StickerItems)
  229. if msg.Content != "" {
  230. predictedLength++
  231. }
  232. parts := make([]*ConvertedMessage, 0, predictedLength)
  233. if textPart := portal.convertDiscordTextMessage(ctx, intent, msg); textPart != nil {
  234. parts = append(parts, textPart)
  235. }
  236. log := zerolog.Ctx(ctx)
  237. handledIDs := make(map[string]struct{})
  238. for _, att := range msg.Attachments {
  239. if _, handled := handledIDs[att.ID]; handled {
  240. continue
  241. }
  242. handledIDs[att.ID] = struct{}{}
  243. log := log.With().Str("attachment_id", att.ID).Logger()
  244. if part := portal.convertDiscordAttachment(log.WithContext(ctx), intent, att); part != nil {
  245. parts = append(parts, part)
  246. }
  247. }
  248. for _, sticker := range msg.StickerItems {
  249. if _, handled := handledIDs[sticker.ID]; handled {
  250. continue
  251. }
  252. handledIDs[sticker.ID] = struct{}{}
  253. log := log.With().Str("sticker_id", sticker.ID).Logger()
  254. if part := portal.convertDiscordSticker(log.WithContext(ctx), intent, sticker); part != nil {
  255. parts = append(parts, part)
  256. }
  257. }
  258. for i, embed := range msg.Embeds {
  259. // Ignore non-video embeds, they're handled in convertDiscordTextMessage
  260. if getEmbedType(embed) != EmbedVideo {
  261. continue
  262. }
  263. // Discord deduplicates embeds by URL. It makes things easier for us too.
  264. if _, handled := handledIDs[embed.URL]; handled {
  265. continue
  266. }
  267. handledIDs[embed.URL] = struct{}{}
  268. log := log.With().
  269. Str("computed_embed_type", "video").
  270. Str("embed_type", string(embed.Type)).
  271. Int("embed_index", i).
  272. Logger()
  273. part := portal.convertDiscordVideoEmbed(log.WithContext(ctx), intent, embed)
  274. if part != nil {
  275. parts = append(parts, part)
  276. }
  277. }
  278. return parts
  279. }
  280. const (
  281. embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
  282. embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
  283. embedHTMLAuthorWithImage = `<p class="discord-embed-author"><img data-mx-emoticon height="24" src="%s" title="Author icon" alt="">&nbsp;<span>%s</span></p>`
  284. embedHTMLAuthorPlain = `<p class="discord-embed-author"><span>%s</span></p>`
  285. embedHTMLAuthorLink = `<a href="%s">%s</a>`
  286. embedHTMLTitleWithLink = `<p class="discord-embed-title"><a href="%s"><strong>%s</strong></a></p>`
  287. embedHTMLTitlePlain = `<p class="discord-embed-title"><strong>%s</strong></p>`
  288. embedHTMLDescription = `<p class="discord-embed-description">%s</p>`
  289. embedHTMLFieldName = `<th>%s</th>`
  290. embedHTMLFieldValue = `<td>%s</td>`
  291. embedHTMLFields = `<table class="discord-embed-fields"><tr>%s</tr><tr>%s</tr></table>`
  292. embedHTMLLinearField = `<p class="discord-embed-field" x-inline="%s"><strong>%s</strong><br><span>%s</span></p>`
  293. embedHTMLImage = `<p class="discord-embed-image"><img src="%s" alt="" title="Embed image"></p>`
  294. 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>`
  295. embedHTMLFooterPlain = `<p class="discord-embed-footer"><sub><span>%s</span>%s</sub></p>`
  296. embedHTMLFooterOnlyDate = `<p class="discord-embed-footer"><sub>%s</sub></p>`
  297. embedHTMLDate = `<time datetime="%s">%s</time>`
  298. embedFooterDateSeparator = ` • `
  299. )
  300. func (portal *Portal) convertDiscordRichEmbed(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed, msgID string, index int) string {
  301. log := zerolog.Ctx(ctx)
  302. var htmlParts []string
  303. if embed.Author != nil {
  304. var authorHTML string
  305. authorNameHTML := html.EscapeString(embed.Author.Name)
  306. if embed.Author.URL != "" {
  307. authorNameHTML = fmt.Sprintf(embedHTMLAuthorLink, embed.Author.URL, authorNameHTML)
  308. }
  309. authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
  310. if embed.Author.ProxyIconURL != "" {
  311. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Author.ProxyIconURL, false, NoMeta)
  312. if err != nil {
  313. log.Warn().Err(err).Msg("Failed to reupload author icon in embed")
  314. } else {
  315. authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, dbFile.MXC, authorNameHTML)
  316. }
  317. }
  318. htmlParts = append(htmlParts, authorHTML)
  319. }
  320. if embed.Title != "" {
  321. var titleHTML string
  322. baseTitleHTML := portal.renderDiscordMarkdownOnlyHTML(embed.Title, false)
  323. if embed.URL != "" {
  324. titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
  325. } else {
  326. titleHTML = fmt.Sprintf(embedHTMLTitlePlain, baseTitleHTML)
  327. }
  328. htmlParts = append(htmlParts, titleHTML)
  329. }
  330. if embed.Description != "" {
  331. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, portal.renderDiscordMarkdownOnlyHTML(embed.Description, true)))
  332. }
  333. for i := 0; i < len(embed.Fields); i++ {
  334. item := embed.Fields[i]
  335. if portal.bridge.Config.Bridge.EmbedFieldsAsTables {
  336. splitItems := []*discordgo.MessageEmbedField{item}
  337. if item.Inline && len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
  338. splitItems = append(splitItems, embed.Fields[i+1])
  339. i++
  340. if len(embed.Fields) > i+1 && embed.Fields[i+1].Inline {
  341. splitItems = append(splitItems, embed.Fields[i+1])
  342. i++
  343. }
  344. }
  345. headerParts := make([]string, len(splitItems))
  346. contentParts := make([]string, len(splitItems))
  347. for j, splitItem := range splitItems {
  348. headerParts[j] = fmt.Sprintf(embedHTMLFieldName, portal.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
  349. contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, portal.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
  350. }
  351. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
  352. } else {
  353. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
  354. strconv.FormatBool(item.Inline),
  355. portal.renderDiscordMarkdownOnlyHTML(item.Name, false),
  356. portal.renderDiscordMarkdownOnlyHTML(item.Value, true),
  357. ))
  358. }
  359. }
  360. if embed.Image != nil {
  361. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Image.ProxyURL, false, NoMeta)
  362. if err != nil {
  363. log.Warn().Err(err).Msg("Failed to reupload image in embed")
  364. } else {
  365. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, dbFile.MXC))
  366. }
  367. }
  368. var embedDateHTML string
  369. if embed.Timestamp != "" {
  370. formattedTime := embed.Timestamp
  371. parsedTS, err := time.Parse(time.RFC3339, embed.Timestamp)
  372. if err != nil {
  373. log.Warn().Err(err).Msg("Failed to parse timestamp in embed")
  374. } else {
  375. formattedTime = parsedTS.Format(discordTimestampStyle('F').Format())
  376. }
  377. embedDateHTML = fmt.Sprintf(embedHTMLDate, embed.Timestamp, formattedTime)
  378. }
  379. if embed.Footer != nil {
  380. var footerHTML string
  381. var datePart string
  382. if embedDateHTML != "" {
  383. datePart = embedFooterDateSeparator + embedDateHTML
  384. }
  385. footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
  386. if embed.Footer.ProxyIconURL != "" {
  387. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, embed.Footer.ProxyIconURL, false, NoMeta)
  388. if err != nil {
  389. log.Warn().Err(err).Msg("Failed to reupload footer icon in embed")
  390. } else {
  391. footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, dbFile.MXC, html.EscapeString(embed.Footer.Text), datePart)
  392. }
  393. }
  394. htmlParts = append(htmlParts, footerHTML)
  395. } else if embed.Timestamp != "" {
  396. htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFooterOnlyDate, embedDateHTML))
  397. }
  398. if len(htmlParts) == 0 {
  399. return ""
  400. }
  401. compiledHTML := strings.Join(htmlParts, "")
  402. if embed.Color != 0 {
  403. compiledHTML = fmt.Sprintf(embedHTMLWrapperColor, embed.Color, compiledHTML)
  404. } else {
  405. compiledHTML = fmt.Sprintf(embedHTMLWrapper, compiledHTML)
  406. }
  407. return compiledHTML
  408. }
  409. type BeeperLinkPreview struct {
  410. mautrix.RespPreviewURL
  411. MatchedURL string `json:"matched_url"`
  412. ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
  413. }
  414. func (portal *Portal) convertDiscordLinkEmbedImage(ctx context.Context, intent *appservice.IntentAPI, url string, width, height int, preview *BeeperLinkPreview) {
  415. dbFile, err := portal.bridge.copyAttachmentToMatrix(intent, url, portal.Encrypted, NoMeta)
  416. if err != nil {
  417. zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview")
  418. return
  419. }
  420. if width != 0 || height != 0 {
  421. preview.ImageWidth = width
  422. preview.ImageHeight = height
  423. } else {
  424. preview.ImageWidth = dbFile.Width
  425. preview.ImageHeight = dbFile.Height
  426. }
  427. preview.ImageSize = dbFile.Size
  428. preview.ImageType = dbFile.MimeType
  429. if dbFile.Encrypted {
  430. preview.ImageEncryption = &event.EncryptedFileInfo{
  431. EncryptedFile: *dbFile.DecryptionInfo,
  432. URL: dbFile.MXC.CUString(),
  433. }
  434. } else {
  435. preview.ImageURL = dbFile.MXC.CUString()
  436. }
  437. }
  438. func (portal *Portal) convertDiscordLinkEmbedToBeeper(ctx context.Context, intent *appservice.IntentAPI, embed *discordgo.MessageEmbed) *BeeperLinkPreview {
  439. var preview BeeperLinkPreview
  440. preview.MatchedURL = embed.URL
  441. preview.Title = embed.Title
  442. preview.Description = embed.Description
  443. if embed.Image != nil {
  444. portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview)
  445. } else if embed.Thumbnail != nil {
  446. portal.convertDiscordLinkEmbedImage(ctx, intent, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview)
  447. }
  448. return &preview
  449. }
  450. const msgInteractionTemplateHTML = `<blockquote>
  451. <a href="https://matrix.to/#/%s">%s</a> used <font color="#3771bb">/%s</font>
  452. </blockquote>`
  453. const msgComponentTemplateHTML = `<p>This message contains interactive elements. Use the Discord app to interact with the message.</p>`
  454. type BridgeEmbedType int
  455. const (
  456. EmbedUnknown BridgeEmbedType = iota
  457. EmbedRich
  458. EmbedLinkPreview
  459. EmbedVideo
  460. )
  461. func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
  462. // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
  463. // so this is a hacky way to detect those.
  464. return embed.Video != nil && embed.Video.ProxyURL == ""
  465. }
  466. func getEmbedType(embed *discordgo.MessageEmbed) BridgeEmbedType {
  467. switch embed.Type {
  468. case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
  469. return EmbedLinkPreview
  470. case discordgo.EmbedTypeVideo:
  471. if isActuallyLinkPreview(embed) {
  472. return EmbedLinkPreview
  473. }
  474. return EmbedVideo
  475. case discordgo.EmbedTypeGifv:
  476. return EmbedVideo
  477. case discordgo.EmbedTypeRich, discordgo.EmbedTypeImage:
  478. return EmbedRich
  479. default:
  480. return EmbedUnknown
  481. }
  482. }
  483. func isPlainGifMessage(msg *discordgo.Message) bool {
  484. return len(msg.Embeds) == 1 && msg.Embeds[0].Video != nil && msg.Embeds[0].URL == msg.Content && msg.Embeds[0].Type == discordgo.EmbedTypeGifv
  485. }
  486. func (portal *Portal) convertDiscordTextMessage(ctx context.Context, intent *appservice.IntentAPI, msg *discordgo.Message) *ConvertedMessage {
  487. log := zerolog.Ctx(ctx)
  488. if msg.Type == discordgo.MessageTypeCall {
  489. return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
  490. MsgType: event.MsgEmote,
  491. Body: "started a call",
  492. }}
  493. } else if msg.Type == discordgo.MessageTypeGuildMemberJoin {
  494. return &ConvertedMessage{Type: event.EventMessage, Content: &event.MessageEventContent{
  495. MsgType: event.MsgEmote,
  496. Body: "joined the server",
  497. }}
  498. }
  499. var htmlParts []string
  500. if msg.Interaction != nil {
  501. puppet := portal.bridge.GetPuppetByID(msg.Interaction.User.ID)
  502. puppet.UpdateInfo(nil, msg.Interaction.User)
  503. htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, puppet.MXID, puppet.Name, msg.Interaction.Name))
  504. }
  505. if msg.Content != "" && !isPlainGifMessage(msg) {
  506. htmlParts = append(htmlParts, portal.renderDiscordMarkdownOnlyHTML(msg.Content, false))
  507. }
  508. previews := make([]*BeeperLinkPreview, 0)
  509. for i, embed := range msg.Embeds {
  510. if i == 0 && msg.MessageReference == nil && isReplyEmbed(embed) {
  511. continue
  512. }
  513. with := log.With().
  514. Str("embed_type", string(embed.Type)).
  515. Int("embed_index", i)
  516. switch getEmbedType(embed) {
  517. case EmbedRich:
  518. log := with.Str("computed_embed_type", "rich").Logger()
  519. htmlParts = append(htmlParts, portal.convertDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i))
  520. case EmbedLinkPreview:
  521. log := with.Str("computed_embed_type", "link preview").Logger()
  522. previews = append(previews, portal.convertDiscordLinkEmbedToBeeper(log.WithContext(ctx), intent, embed))
  523. case EmbedVideo:
  524. // Ignore video embeds, they're handled as separate messages
  525. default:
  526. log := with.Logger()
  527. log.Warn().Msg("Unknown embed type in message")
  528. }
  529. }
  530. if len(msg.Components) > 0 {
  531. htmlParts = append(htmlParts, msgComponentTemplateHTML)
  532. }
  533. if len(htmlParts) == 0 {
  534. return nil
  535. }
  536. fullHTML := strings.Join(htmlParts, "\n")
  537. if !msg.MentionEveryone {
  538. fullHTML = strings.ReplaceAll(fullHTML, "@room", "@\u2063ro\u2063om")
  539. }
  540. content := format.HTMLToContent(fullHTML)
  541. extraContent := map[string]any{
  542. "com.beeper.linkpreviews": previews,
  543. }
  544. return &ConvertedMessage{Type: event.EventMessage, Content: &content, Extra: extraContent}
  545. }