portal_convert.go 19 KB

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