From d82b74fb29cad47282507fc85335fca6d45d0aa6 Mon Sep 17 00:00:00 2001 From: Skip R Date: Tue, 16 Dec 2025 18:35:29 -0800 Subject: [PATCH] handlematrix: handle basic matrix rich text messages Added the necessary room capabilities, too. Support for replies, editing, deletion, and attachments are forthcoming. --- pkg/connector/capabilities.go | 26 +++++++++ pkg/connector/handlematrix.go | 38 ++++++++++++- pkg/msgconv/embed.go | 3 +- pkg/msgconv/formatter.go | 76 ++++++++++++++++++++++++- pkg/msgconv/from-matrix.go | 104 ++++++++++++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 pkg/msgconv/from-matrix.go diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 4728513..aceb5d5 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -56,6 +56,32 @@ func capID() string { var discordCaps = &event.RoomFeatures{ ID: capID(), + Formatting: event.FormattingFeatureMap{ + event.FmtBold: event.CapLevelFullySupported, + event.FmtItalic: event.CapLevelFullySupported, + event.FmtStrikethrough: event.CapLevelFullySupported, + event.FmtInlineCode: event.CapLevelFullySupported, + event.FmtCodeBlock: event.CapLevelFullySupported, + event.FmtSyntaxHighlighting: event.CapLevelFullySupported, + event.FmtBlockquote: event.CapLevelFullySupported, + event.FmtInlineLink: event.CapLevelFullySupported, + event.FmtUserLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support. + event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support. + event.FmtUnorderedList: event.CapLevelFullySupported, + event.FmtOrderedList: event.CapLevelFullySupported, + event.FmtListStart: event.CapLevelFullySupported, + event.FmtListJumpValue: event.CapLevelUnsupported, + event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support. + }, + LocationMessage: event.CapLevelUnsupported, + // TODO: This limit is increased depending on Discord subscription (Nitro). + MaxTextLength: 2000, + // TODO: Support reactions. + // TODO: Support threads. + // TODO: Support editing. + // TODO: Support message deletion. } func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index d8084f9..ab6d89f 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -19,8 +19,10 @@ package connector import ( "context" + "github.com/bwmarrin/discordgo" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" ) var ( @@ -31,9 +33,39 @@ var ( _ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil) ) -func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { - //TODO implement me - panic("implement me") +func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + if d.Session == nil { + return nil, bridgev2.ErrNotLoggedIn + } + + portal := msg.Portal + channelID := string(portal.ID) + + // TODO: Support replies. + + sendReq, err := d.connector.MsgConv.ToDiscord(ctx, msg) + if err != nil { + return nil, err + } + + var options []discordgo.RequestOption + // TODO: When supporting threads (and not a bot user), send a thread referer. + // 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...) + if err != nil { + return nil, err + } + sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID) + + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: networkid.MessageID(sentMsg.ID), + SenderID: networkid.UserID(sentMsg.Author.ID), + Timestamp: sentMsgTimestamp, + }, + }, nil } func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go index 9f63f7d..79c1f96 100644 --- a/pkg/msgconv/embed.go +++ b/pkg/msgconv/embed.go @@ -37,8 +37,7 @@ const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff // don't contain < or whitespace anywhere, and don't end with "'),.:;] // // Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason -// FIXME(skip): This will be unused until we port `escapeDiscordMarkdown`. -// var discordLinkRegex = regexp.MustCompile(discordLinkPattern) +var discordLinkRegex = regexp.MustCompile(discordLinkPattern) var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$") func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool { diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go index 7065bc6..8a9fa3f 100644 --- a/pkg/msgconv/formatter.go +++ b/pkg/msgconv/formatter.go @@ -19,6 +19,7 @@ package msgconv import ( "fmt" "regexp" + "slices" "strings" "github.com/yuin/goldmark" @@ -96,4 +97,77 @@ func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridge return buf.String() } -// TODO(skip): Stopping here for now. Continue at formatterContextPortalKey. +const formatterContextPortalKey = "fi.mau.discord.portal" +const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions" +const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions" +const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews" + +var discordMarkdownEscaper = strings.NewReplacer( + `\`, `\\`, + `_`, `\_`, + `*`, `\*`, + `~`, `\~`, + "`", "\\`", + `|`, `\|`, + `<`, `\<`, + `#`, `\#`, +) + +func escapeDiscordMarkdown(s string) string { + submatches := discordLinkRegex.FindAllStringIndex(s, -1) + if submatches == nil { + return discordMarkdownEscaper.Replace(s) + } + var builder strings.Builder + offset := 0 + for _, match := range submatches { + start := match[0] + end := match[1] + builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start])) + builder.WriteString(s[start:end]) + offset = end + } + builder.WriteString(discordMarkdownEscaper.Replace(s[offset:])) + return builder.String() +} + +var matrixHTMLParser = &format.HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + HorizontalLine: "\n---\n", + ItalicConverter: func(s string, ctx format.Context) string { + return fmt.Sprintf("*%s*", s) + }, + UnderlineConverter: func(s string, ctx format.Context) string { + return fmt.Sprintf("__%s__", s) + }, + TextConverter: func(s string, ctx format.Context) string { + if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") { + // If we're in a code block, don't escape markdown + return s + } + return escapeDiscordMarkdown(s) + }, + SpoilerConverter: func(text, reason string, ctx format.Context) string { + if reason != "" { + return fmt.Sprintf("(%s) ||%s||", reason, text) + } + return fmt.Sprintf("||%s||", text) + }, + LinkConverter: func(text, href string, ctx format.Context) string { + linkPreviews := ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey].([]string) + allowPreview := linkPreviews == nil || slices.Contains(linkPreviews, href) + if text == href { + if !allowPreview { + return fmt.Sprintf("<%s>", text) + } + return text + } else if !discordLinkRegexFull.MatchString(href) { + return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href)) + } else if !allowPreview { + return fmt.Sprintf("[%s](<%s>)", escapeDiscordMarkdown(text), href) + } else { + return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href) + } + }, +} diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go new file mode 100644 index 0000000..5340757 --- /dev/null +++ b/pkg/msgconv/from-matrix.go @@ -0,0 +1,104 @@ +// 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 + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/bwmarrin/discordgo" + "go.mau.fi/util/variationselector" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" +) + +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 +} + +// 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, + msg *bridgev2.MatrixMessage, +) (discordgo.MessageSend, error) { + var req discordgo.MessageSend + req.Nonce = generateMessageNonce() + + 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 { + req.Content = fmt.Sprintf("_%s_", req.Content) + } + // TODO: Handle attachments. + } + + // 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 + } +}