Files
mautrix-discord/pkg/msgconv/from-matrix.go
Skip R 92352ce603 discordid: remove all ID-related casts
In the same vein as mautrix-whatsapp, -slack and others, do not make
assumptions about how the ID is represented in the connector code. Let
the discordid package be entirely responsible.
2026-02-01 21:09:17 -08:00

205 lines
6.5 KiB
Go

// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
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"
"maunium.net/go/mautrix/format"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
const discordEpochMillis = 1420070400000
func generateMessageNonce() string {
snowflake := (time.Now().UnixMilli() - discordEpochMillis) << 22
// Nonce snowflakes don't have internal IDs or increments
return strconv.FormatInt(snowflake, 10)
}
func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil {
return nil
}
linkPreviews, ok := raw["com.beeper.linkpreviews"].([]any)
if !ok {
return nil
}
allowedLinkPreviews := make([]string, 0, len(linkPreviews))
for _, preview := range linkPreviews {
previewMap, ok := preview.(map[string]any)
if !ok {
continue
}
matchedURL, _ := previewMap["matched_url"].(string)
if matchedURL != "" {
allowedLinkPreviews = append(allowedLinkPreviews, matchedURL)
}
}
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) {
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
var req discordgo.MessageSend
req.Nonce = generateMessageNonce()
log := zerolog.Ctx(ctx)
if msg.ReplyTo != nil {
req.Reference = &discordgo.MessageReference{
ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID),
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
}
}
portal := msg.Portal
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(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)
}
}
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: Support threads.
}, discordgo.WithChannelReferer(guildID, 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
}
func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},
RepliedUser: true,
}
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
ctx := format.NewContext(ctx)
ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
ctx.ReturnData[formatterContextPortalKey] = portal
ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
if content.Mentions != nil {
ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
}
return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
} else {
return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
}
}