diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go new file mode 100644 index 0000000..69d3b55 --- /dev/null +++ b/pkg/connector/attachments.go @@ -0,0 +1,126 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 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 . + +package connector + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + for key, value := range discordgo.DroidDownloadHeaders { + req.Header.Set(key, value) + } + + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode > 300 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data) + } + if resp.Header.Get("Content-Length") != "" { + length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse content length: %w", err) + } else if length > maxSize { + return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize) + } + return io.ReadAll(resp.Body) + } else { + var mbe *http.MaxBytesError + data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize)) + if err != nil && errors.As(err, &mbe) { + return nil, fmt.Errorf("attachment too large (over %d)", maxSize) + } + return data, err + } +} + +type AttachmentReupload struct { + DownloadingURL string + FileName string + MimeType string +} + +type ReuploadedAttachment struct { + AttachmentReupload + DownloadedSize int + MXC id.ContentURIString + EncryptedFile *event.EncryptedFileInfo +} + +func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload AttachmentReupload) (*ReuploadedAttachment, error) { + log := zerolog.Ctx(ctx) + // TODO(skip): Do we need to check if we've already downloaded this media before? + // TODO(skip): Read a maximum size from the config. + data, err := downloadDiscordAttachment(http.DefaultClient, upload.DownloadingURL, 1_024*1_024*50) + if err != nil { + return nil, fmt.Errorf("couldn't download attachment for reupload: %w", err) + } + + if upload.FileName == "" { + url, err := url.Parse(upload.DownloadingURL) + if err != nil { + return nil, fmt.Errorf("couldn't parse URL to download for media reupload: %w", err) + } + fileName := path.Base(url.Path) + upload.FileName = fileName + log.Trace().Str("detected_file_name", fileName).Msg("Inferred the file name of the media we're reuploading") + } + + if upload.MimeType == "" { + mime := http.DetectContentType(data) + upload.MimeType = mime + log.Trace().Str("detected_mime_type", mime).Msg("Inferred the mime type of the media we're reuploading") + } + + log.Trace().Stringer("portal_mxid", portal.MXID). + Int("attachment_size", len(data)). + Str("file_name", upload.FileName). + Str("mime_type", upload.MimeType). + Msg("Uploading downloaded media") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, upload.FileName, upload.MimeType) + if err != nil { + return nil, err + } + + return &ReuploadedAttachment{ + AttachmentReupload: upload, + DownloadedSize: len(data), + MXC: mxc, + EncryptedFile: file, + }, nil +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4eef523..ac04139 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -151,11 +151,11 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) { func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { if cl.UserLogin == nil { - cl.connector.bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin") + cl.connector.Bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin") return } if cl.hasBegunSyncing { - cl.connector.bridge.Log.Warn().Msg("Not beginning sync more than once") + cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once") return } cl.hasBegunSyncing = true @@ -251,7 +251,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se members.TotalMemberCount = len(ch.Recipients) } - d.connector.bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ + d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ channel: ch, portalKey: MakePortalKey(ch, d.UserLogin.ID, true), info: &bridgev2.ChatInfo{ diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e3a16d8..56c156b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -23,13 +23,13 @@ import ( ) type DiscordConnector struct { - bridge *bridgev2.Bridge + Bridge *bridgev2.Bridge } var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { - d.bridge = bridge + d.Bridge = bridge } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go new file mode 100644 index 0000000..b03bf19 --- /dev/null +++ b/pkg/msgconv/formatter_tag.go @@ -0,0 +1,40 @@ +// mautrix-discord - A Matrix-Discord puppeting bridge. +// Copyright (C) 2024 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 . + +package msgconv + +// TODO(skip): Port the rest of this. + +type discordTimestampStyle rune + +func (dts discordTimestampStyle) Format() string { + switch dts { + case 't': + return "15:04 MST" + case 'T': + return "15:04:05 MST" + case 'd': + return "2006-01-02 MST" + case 'D': + return "2 January 2006 MST" + case 'F': + return "Monday, 2 January 2006 15:04 MST" + case 'f': + fallthrough + default: + return "2 January 2006 15:04 MST" + } +} diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go index 075828f..66cbb4f 100644 --- a/pkg/msgconv/from-discord.go +++ b/pkg/msgconv/from-discord.go @@ -19,7 +19,10 @@ package msgconv import ( "context" "fmt" + "html" + "strconv" "strings" + "time" "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" @@ -42,7 +45,7 @@ func (mc *MessageConverter) ToMatrix( predictedLength++ } parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength) - if textPart := mc.renderDiscordTextMessage(ctx, intent, msg, source); textPart != nil { + if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil { parts = append(parts, textPart) } @@ -56,7 +59,7 @@ func (mc *MessageConverter) ToMatrix( handledIDs[att.ID] = struct{}{} log := log.With().Str("attachment_id", att.ID).Logger() - if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, msg.ID, att); part != nil { + if part := mc.renderDiscordAttachment(log.WithContext(ctx), intent, portal, att); part != nil { parts = append(parts, part) } } @@ -89,7 +92,7 @@ func (mc *MessageConverter) ToMatrix( Str("embed_type", string(embed.Type)). Int("embed_index", i). Logger() - part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, embed) + part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), intent, portal, embed) if part != nil { parts = append(parts, part) } @@ -123,7 +126,7 @@ const msgInteractionTemplateHTML = `
const msgComponentTemplateHTML = `

This message contains interactive elements. Use the Discord app to interact with the message.

` -func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { +func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *discordgo.Message, source *bridgev2.UserLogin) *bridgev2.ConvertedMessagePart { log := zerolog.Ctx(ctx) if msg.Type == discordgo.MessageTypeCall { return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{ @@ -140,7 +143,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent var htmlParts []string if msg.Interaction != nil { - ghost, err := mc.bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) + ghost, err := mc.connector.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) // TODO(skip): Try doing ghost.UpdateInfoIfNecessary. if err == nil { htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) @@ -161,12 +164,12 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true) msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") origLink := fmt.Sprintf("unknown channel • %s", msgTSText) - if forwardedFromPortal, err := mc.bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { - if origMessage, err := mc.bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { + if forwardedFromPortal, err := mc.connector.Bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { + if origMessage, err := mc.connector.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil { // We've bridged the message that was forwarded, so we can link to it directly. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -176,7 +179,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent // We don't have the message but we have the portal, so link to that. origLink = fmt.Sprintf( `#%s • %s`, - forwardedFromPortal.MXID.URI(mc.bridge.Matrix.ServerName()), + forwardedFromPortal.MXID.URI(mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.Name, msgTSText, ) @@ -204,12 +207,12 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent switch getEmbedType(msg, embed) { case EmbedRich: log := with.Str("computed_embed_type", "rich").Logger() - htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, embed, msg.ID, i)) + htmlParts = append(htmlParts, mc.renderDiscordRichEmbed(log.WithContext(ctx), intent, portal, embed)) case EmbedLinkPreview: log := with.Str("computed_embed_type", "link preview").Logger() - previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, embed)) + previews = append(previews, mc.renderDiscordLinkEmbed(log.WithContext(ctx), intent, portal, embed)) case EmbedVideo: - // Ignore video embeds, they're handled as separate messages. + // Video embeds are handled as separate messages via renderDiscordVideoEmbed. default: log := with.Logger() log.Warn().Msg("Unknown embed type in message") @@ -237,8 +240,81 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent return &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &content, Extra: extraContent} } -func (mc *MessageConverter) renderDiscordVideoEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { - panic("unimplemented") +func mediaFailedMessage(err error) *event.MessageEventContent { + return &event.MessageEventContent{ + Body: fmt.Sprintf("Failed to bridge media: %v", err), + MsgType: event.MsgNotice, + } +} + +func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *bridgev2.ConvertedMessagePart { + var proxyURL string + if embed.Video != nil { + proxyURL = embed.Video.ProxyURL + } else if embed.Thumbnail != nil { + proxyURL = embed.Thumbnail.ProxyURL + } else { + zerolog.Ctx(ctx).Warn().Str("embed_url", embed.URL).Msg("No video or thumbnail proxy URL found in embed") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + Body: "Failed to bridge media: no video or thumbnail proxy URL found in embed", + MsgType: event.MsgNotice, + }, + } + } + + upload := connector.AttachmentReupload{ + DownloadingURL: proxyURL, + } + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, upload) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: mediaFailedMessage(err), + } + } + + content := &event.MessageEventContent{ + Body: embed.URL, + Info: &event.FileInfo{ + MimeType: reupload.MimeType, + Size: reupload.DownloadedSize, + }, + File: &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + }, + } + + 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 + } + + 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.gif": true, + "fi.mau.loop": true, + "fi.mau.autoplay": true, + "fi.mau.hide_controls": true, + "fi.mau.no_audio": true, + } + } + + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + Extra: extra, + } } func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent bridgev2.MatrixAPI, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart { @@ -253,14 +329,257 @@ func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, a panic("unimplemented") } -func (mc *MessageConverter) renderDiscordRichEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed, messageID string, i int) string { - panic("unimplemented") +const ( + embedHTMLWrapper = `
%s
` + embedHTMLWrapperColor = `
%s
` + embedHTMLAuthorWithImage = `

 %s

` + embedHTMLAuthorPlain = `

%s

` + embedHTMLAuthorLink = `%s` + embedHTMLTitleWithLink = `

%s

` + embedHTMLTitlePlain = `

%s

` + embedHTMLDescription = `

%s

` + embedHTMLFieldName = `%s` + embedHTMLFieldValue = `%s` + embedHTMLFields = `%s%s
` + embedHTMLLinearField = `

%s
%s

` + embedHTMLImage = `

` + embedHTMLFooterWithImage = `` + embedHTMLFooterPlain = `` + embedHTMLFooterOnlyDate = `` + embedHTMLDate = `` + embedFooterDateSeparator = ` • ` +) + +func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) 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 != "" { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Author.ProxyIconURL, + }) + + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload author icon in embed") + } else { + authorHTML = fmt.Sprintf(embedHTMLAuthorWithImage, reupload.MXC, authorNameHTML) + } + } + htmlParts = append(htmlParts, authorHTML) + } + + if embed.Title != "" { + var titleHTML string + baseTitleHTML := mc.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, mc.renderDiscordMarkdownOnlyHTML(embed.Description, true))) + } + + for i := 0; i < len(embed.Fields); i++ { + item := embed.Fields[i] + // TODO(skip): Port EmbedFieldsAsTables. + if false { + 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, mc.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) + contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.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), + mc.renderDiscordMarkdownOnlyHTML(item.Name, false), + mc.renderDiscordMarkdownOnlyHTML(item.Value, true), + )) + } + } + + if embed.Image != nil { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Image.ProxyURL, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload image in embed") + } else { + htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLImage, reupload.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 != "" { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: embed.Footer.ProxyIconURL, + }) + + if err != nil { + log.Warn().Err(err).Msg("Failed to reupload footer icon in embed") + } else { + footerHTML = fmt.Sprintf(embedHTMLFooterWithImage, reupload.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 } -func (mc *MessageConverter) renderDiscordLinkEmbed(context context.Context, intent bridgev2.MatrixAPI, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { - panic("unimplemented") +func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: url, + }) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to reupload image in URL preview, ignoring") + return + } + + if width != 0 || height != 0 { + preview.ImageWidth = event.IntOrString(width) + preview.ImageHeight = event.IntOrString(height) + } + preview.ImageSize = event.IntOrString(reupload.DownloadedSize) + preview.ImageType = reupload.MimeType + preview.ImageEncryption = &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + } } -func (mc *MessageConverter) renderDiscordAttachment(context context.Context, intent bridgev2.MatrixAPI, d string, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { - panic("unimplemented") +func (mc *MessageConverter) renderDiscordLinkEmbed(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, embed *discordgo.MessageEmbed) *event.BeeperLinkPreview { + var preview event.BeeperLinkPreview + preview.MatchedURL = embed.URL + preview.Title = embed.Title + preview.Description = embed.Description + if embed.Image != nil { + mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Image.ProxyURL, embed.Image.Width, embed.Image.Height, &preview) + } else if embed.Thumbnail != nil { + mc.renderDiscordLinkEmbedImage(ctx, intent, portal, embed.Thumbnail.ProxyURL, embed.Thumbnail.Width, embed.Thumbnail.Height, &preview) + } + return &preview +} + +func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, att *discordgo.MessageAttachment) *bridgev2.ConvertedMessagePart { + content := &event.MessageEventContent{ + Body: att.Filename, + Info: &event.FileInfo{ + Width: att.Width, + Height: att.Height, + MimeType: att.ContentType, + + // This gets overwritten later after the file is uploaded to the homeserver + Size: att.Size, + }, + } + + var extra = make(map[string]any) + + if strings.HasPrefix(att.Filename, "SPOILER_") { + extra["page.codeberg.everypizza.msc4193.spoiler"] = true + } + + if att.Description != "" { + content.Body = att.Description + content.FileName = att.Filename + } + + switch strings.ToLower(strings.Split(att.ContentType, "/")[0]) { + case "audio": + content.MsgType = event.MsgAudio + if att.Waveform != nil { + // Bridge a voice message. + + // TODO convert waveform + extra["org.matrix.msc1767.audio"] = map[string]any{ + "duration": int(att.DurationSeconds * 1000), + } + extra["org.matrix.msc3245.voice"] = map[string]any{} + } + case "image": + content.MsgType = event.MsgImage + case "video": + content.MsgType = event.MsgVideo + default: + content.MsgType = event.MsgFile + } + + // TODO(skip): Support direct media. + reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ + DownloadingURL: att.URL, + }) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to copy attachment to Matrix") + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: mediaFailedMessage(err), + } + } + + content.Info.Size = reupload.DownloadedSize + if content.Info.Width == 0 && content.Info.Height == 0 { + content.Info.Width = att.Width + content.Info.Height = att.Height + } + content.File = &event.EncryptedFileInfo{ + EncryptedFile: reupload.EncryptedFile.EncryptedFile, + URL: reupload.MXC, + } + + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + Extra: extra, + } } diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 03a4aee..15237df 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -16,8 +16,10 @@ package msgconv -import "maunium.net/go/mautrix/bridgev2" +import ( + "go.mau.fi/mautrix-discord/pkg/connector" +) type MessageConverter struct { - bridge *bridgev2.Bridge + connector *connector.DiscordConnector }