handlematrix: bridge outgoing message attachments
This commit is contained in:
@@ -54,6 +54,13 @@ func capID() string {
|
|||||||
return base
|
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{
|
var discordCaps = &event.RoomFeatures{
|
||||||
ID: capID(),
|
ID: capID(),
|
||||||
Reply: event.CapLevelFullySupported,
|
Reply: event.CapLevelFullySupported,
|
||||||
@@ -78,9 +85,57 @@ var discordCaps = &event.RoomFeatures{
|
|||||||
event.FmtListJumpValue: event.CapLevelUnsupported,
|
event.FmtListJumpValue: event.CapLevelUnsupported,
|
||||||
event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support.
|
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,
|
LocationMessage: event.CapLevelUnsupported,
|
||||||
// TODO: This limit is increased depending on Discord subscription (Nitro).
|
MaxTextLength: MaxTextLength,
|
||||||
MaxTextLength: 2000,
|
|
||||||
// TODO: Support reactions.
|
// TODO: Support reactions.
|
||||||
// TODO: Support threads.
|
// TODO: Support threads.
|
||||||
// TODO: Support editing.
|
// TODO: Support editing.
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import (
|
|||||||
"maunium.net/go/mautrix/bridgev2/status"
|
"maunium.net/go/mautrix/bridgev2/status"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/pkg/discordid"
|
"go.mau.fi/mautrix-discord/pkg/discordid"
|
||||||
"go.mau.fi/mautrix-discord/pkg/msgconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiscordClient struct {
|
type DiscordClient struct {
|
||||||
@@ -42,7 +41,6 @@ type DiscordClient struct {
|
|||||||
UserLogin *bridgev2.UserLogin
|
UserLogin *bridgev2.UserLogin
|
||||||
Session *discordgo.Session
|
Session *discordgo.Session
|
||||||
hasBegunSyncing bool
|
hasBegunSyncing bool
|
||||||
MsgConv msgconv.MessageConverter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
|
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,
|
connector: d,
|
||||||
UserLogin: login,
|
UserLogin: login,
|
||||||
Session: session,
|
Session: session,
|
||||||
MsgConv: msgconv.MessageConverter{
|
|
||||||
Bridge: d.Bridge,
|
|
||||||
ReuploadMedia: d.ReuploadMedia,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
cl.SetUp(ctx, meta)
|
cl.SetUp(ctx, meta)
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ var _ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
|
|||||||
|
|
||||||
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
|
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
|
||||||
d.Bridge = bridge
|
d.Bridge = bridge
|
||||||
d.MsgConv = &msgconv.MessageConverter{
|
d.MsgConv = msgconv.NewMessageConverter(bridge, d.ReuploadMedia)
|
||||||
Bridge: bridge,
|
|
||||||
ReuploadMedia: d.ReuploadMedia,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DiscordConnector) Start(ctx context.Context) error {
|
func (d *DiscordConnector) Start(ctx context.Context) error {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"maunium.net/go/mautrix/bridgev2"
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
"maunium.net/go/mautrix/bridgev2/database"
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
@@ -41,7 +42,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
|
|||||||
portal := msg.Portal
|
portal := msg.Portal
|
||||||
channelID := string(portal.ID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// TODO: Pass the guild ID when send messages in guild channels.
|
||||||
options = append(options, discordgo.WithChannelReferer("", channelID))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
package msgconv
|
package msgconv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"go.mau.fi/util/variationselector"
|
"go.mau.fi/util/variationselector"
|
||||||
"maunium.net/go/mautrix/bridgev2"
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
@@ -59,14 +63,43 @@ func parseAllowedLinkPreviews(raw map[string]any) []string {
|
|||||||
return allowedLinkPreviews
|
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
|
// ToDiscord converts a Matrix message into a discordgo.MessageSend that is appropriate
|
||||||
// for bridging the message to Discord.
|
// for bridging the message to Discord.
|
||||||
func (mc *MessageConverter) ToDiscord(
|
func (mc *MessageConverter) ToDiscord(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
session *discordgo.Session,
|
||||||
msg *bridgev2.MatrixMessage,
|
msg *bridgev2.MatrixMessage,
|
||||||
) (discordgo.MessageSend, error) {
|
) (*discordgo.MessageSend, error) {
|
||||||
var req discordgo.MessageSend
|
var req discordgo.MessageSend
|
||||||
req.Nonce = generateMessageNonce()
|
req.Nonce = generateMessageNonce()
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
|
||||||
if msg.ReplyTo != nil {
|
if msg.ReplyTo != nil {
|
||||||
req.Reference = &discordgo.MessageReference{
|
req.Reference = &discordgo.MessageReference{
|
||||||
@@ -75,18 +108,73 @@ func (mc *MessageConverter) ToDiscord(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.Content.MsgType {
|
portal := msg.Portal
|
||||||
case event.MsgText, event.MsgEmote, event.MsgNotice:
|
channelID := string(portal.ID)
|
||||||
req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, msg.Content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
|
content := msg.Content
|
||||||
if msg.Content.MsgType == event.MsgEmote {
|
|
||||||
|
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)
|
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.
|
// 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) {
|
func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
|
||||||
|
|||||||
@@ -18,19 +18,41 @@ package msgconv
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/bridgev2"
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-discord/pkg/attachment"
|
"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 {
|
type MessageConverter struct {
|
||||||
Bridge *bridgev2.Bridge
|
Bridge *bridgev2.Bridge
|
||||||
|
|
||||||
|
nextDiscordUploadID atomic.Int32
|
||||||
|
|
||||||
// ReuploadMedia is called when the message converter wants to upload some
|
// ReuploadMedia is called when the message converter wants to upload some
|
||||||
// media it is attempting to bridge.
|
// media it is attempting to bridge.
|
||||||
//
|
//
|
||||||
// This can be directly forwarded to the ReuploadMedia method on DiscordConnector.
|
// This can be directly forwarded to the ReuploadMedia method on DiscordConnector.
|
||||||
// The indirection is only necessary to prevent an import cycle.
|
// 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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user