From e71075cd0d50ea84aa3880f919e947ef4bed274c Mon Sep 17 00:00:00 2001 From: Skip R Date: Mon, 5 Jan 2026 22:12:39 -0800 Subject: [PATCH] handlematrix: bridge outgoing message attachments --- pkg/connector/capabilities.go | 59 +++++++++++++++++++- pkg/connector/client.go | 6 -- pkg/connector/connector.go | 5 +- pkg/connector/handlematrix.go | 5 +- pkg/msgconv/from-matrix.go | 102 +++++++++++++++++++++++++++++++--- pkg/msgconv/msgconv.go | 24 +++++++- 6 files changed, 179 insertions(+), 22 deletions(-) diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index f55a701..9cbf20d 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -54,6 +54,13 @@ func capID() string { return base } +// TODO: This limit is increased depending on user subscription status (Discord Nitro). +const MaxTextLength = 2000 + +// TODO: This limit is increased depending on user subscription status (Discord Nitro). +// TODO: Verify this figure (10 MiB). +const MaxFileSize = 10485760 + var discordCaps = &event.RoomFeatures{ ID: capID(), Reply: event.CapLevelFullySupported, @@ -78,9 +85,57 @@ var discordCaps = &event.RoomFeatures{ event.FmtListJumpValue: event.CapLevelUnsupported, event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support. }, + File: event.FileFeatureMap{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelFullySupported, + "image/gif": event.CapLevelFullySupported, + "image/webp": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/mpeg": event.CapLevelFullySupported, + "audio/webm": event.CapLevelFullySupported, + "audio/wav": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + event.CapMsgGIF: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxTextLength, + MaxSize: MaxFileSize, + }, + // TODO: Support voice messages. + }, LocationMessage: event.CapLevelUnsupported, - // TODO: This limit is increased depending on Discord subscription (Nitro). - MaxTextLength: 2000, + MaxTextLength: MaxTextLength, // TODO: Support reactions. // TODO: Support threads. // TODO: Support editing. diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 66d3a91..6ab8d45 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -33,7 +33,6 @@ import ( "maunium.net/go/mautrix/bridgev2/status" "go.mau.fi/mautrix-discord/pkg/discordid" - "go.mau.fi/mautrix-discord/pkg/msgconv" ) type DiscordClient struct { @@ -42,7 +41,6 @@ type DiscordClient struct { UserLogin *bridgev2.UserLogin Session *discordgo.Session hasBegunSyncing bool - MsgConv msgconv.MessageConverter } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { @@ -59,10 +57,6 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us connector: d, UserLogin: login, Session: session, - MsgConv: msgconv.MessageConverter{ - Bridge: d.Bridge, - ReuploadMedia: d.ReuploadMedia, - }, } cl.SetUp(ctx, meta) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ba1fd09..5353e1e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -33,10 +33,7 @@ var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil) func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) { d.Bridge = bridge - d.MsgConv = &msgconv.MessageConverter{ - Bridge: bridge, - ReuploadMedia: d.ReuploadMedia, - } + d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia) } func (d *DiscordConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 7e13fd7..3cdebe3 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -20,6 +20,7 @@ import ( "context" "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -41,7 +42,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M portal := msg.Portal channelID := string(portal.ID) - sendReq, err := d.connector.MsgConv.ToDiscord(ctx, msg) + sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg) if err != nil { return nil, err } @@ -51,7 +52,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M // TODO: Pass the guild ID when send messages in guild channels. options = append(options, discordgo.WithChannelReferer("", channelID)) - sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), &sendReq, options...) + sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), sendReq, options...) if err != nil { return nil, err } diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 3729c31..a6ec736 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -17,12 +17,16 @@ package msgconv import ( + "bytes" "context" "fmt" + "io" + "net/http" "strconv" "time" "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" "go.mau.fi/util/variationselector" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" @@ -59,14 +63,43 @@ func parseAllowedLinkPreviews(raw map[string]any) []string { return allowedLinkPreviews } +func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error { + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) + if err != nil { + return err + } + + for key, value := range discordgo.DroidBaseHeaders { + req.Header.Set(key, value) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Referer", "https://discord.com/") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + resp, err := cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode > 300 { + respData, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData) + } + return nil +} + // ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate // for bridging the message to Discord. func (mc *MessageConverter) ToDiscord( ctx context.Context, + session *discordgo.Session, msg *bridgev2.MatrixMessage, -) (discordgo.MessageSend, error) { +) (*discordgo.MessageSend, error) { var req discordgo.MessageSend req.Nonce = generateMessageNonce() + log := zerolog.Ctx(ctx) if msg.ReplyTo != nil { req.Reference = &discordgo.MessageReference{ @@ -75,18 +108,73 @@ func (mc *MessageConverter) ToDiscord( } } - switch msg.Content.MsgType { - case event.MsgText, event.MsgEmote, event.MsgNotice: - req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, msg.Content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) - if msg.Content.MsgType == event.MsgEmote { + portal := msg.Portal + channelID := string(portal.ID) + content := msg.Content + + convertMatrix := func() { + req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw)) + if content.MsgType == event.MsgEmote { req.Content = fmt.Sprintf("_%s_", req.Content) } - // TODO: Handle attachments. + } + + switch content.MsgType { + case event.MsgText, event.MsgEmote, event.MsgNotice: + convertMatrix() + case event.MsgAudio, event.MsgFile, event.MsgVideo: + mediaData, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) + if err != nil { + log.Err(err).Msg("Failed to download Matrix attachment for bridging") + return nil, bridgev2.ErrMediaDownloadFailed + } + + filename := content.Body + if content.FileName != "" && content.FileName != content.Body { + filename = content.FileName + convertMatrix() + } + if msg.Event.Content.Raw["page.codeberg.everypizza.msc4193.spoiler"] == true { + filename = "SPOILER_" + filename + } + + // TODO: Support attachments for relay/webhook. (A branch was removed here.) + att := &discordgo.MessageAttachment{ + ID: "0", + Filename: filename, + } + + upload_id := mc.NextDiscordUploadID() + log.Debug().Str("upload_id", upload_id).Msg("Preparing attachment") + prep, err := session.ChannelAttachmentCreate(channelID, &discordgo.ReqPrepareAttachments{ + Files: []*discordgo.FilePrepare{{ + Size: len(mediaData), + Name: att.Filename, + ID: mc.NextDiscordUploadID(), + }}, + // TODO: Populate with guild ID. Support threads. + }, discordgo.WithChannelReferer("", channelID)) + + if err != nil { + log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload") + return nil, bridgev2.ErrMediaReuploadFailed + } + + prepared := prep.Attachments[0] + att.UploadedFilename = prepared.UploadFilename + + err = uploadDiscordAttachment(session.Client, prepared.UploadURL, mediaData) + if err != nil { + log.Err(err).Msg("Failed to reupload Discord attachment after preparing") + return nil, bridgev2.ErrMediaReuploadFailed + } + + req.Attachments = append(req.Attachments, att) } // TODO: Handle (silent) replies and allowed mentions. - return req, nil + return &req, nil } func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) { diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 7d51f84..f27a12b 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -18,19 +18,41 @@ package msgconv import ( "context" + "math/rand" + "strconv" + "sync/atomic" "maunium.net/go/mautrix/bridgev2" "go.mau.fi/mautrix-discord/pkg/attachment" ) +type MediaReuploader func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) + type MessageConverter struct { Bridge *bridgev2.Bridge + nextDiscordUploadID atomic.Int32 + // ReuploadMedia is called when the message converter wants to upload some // media it is attempting to bridge. // // This can be directly forwarded to the ReuploadMedia method on DiscordConnector. // The indirection is only necessary to prevent an import cycle. - ReuploadMedia func(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, reupload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) + ReuploadMedia MediaReuploader +} + +func NewMessageConverter(bridge *bridgev2.Bridge, reuploader MediaReuploader) *MessageConverter { + mc := &MessageConverter{ + Bridge: bridge, + ReuploadMedia: reuploader, + } + + mc.nextDiscordUploadID.Store(rand.Int31n(100)) + return mc +} + +func (mc *MessageConverter) NextDiscordUploadID() string { + val := mc.nextDiscordUploadID.Add(2) + return strconv.Itoa(int(val)) }