msgconv: port most of attachment and text message bridging

* Created a separate discordid package to avoid import cycles.
* Implemented attachment bridging. We still need to implement direct
  media, but this will do for now.
* Corrected how encrypted files (e.g. embed images and attachments) were
  bridged. Previously, the URL field would be empty.

Still a lot of missing pieces. Thoughts:

* Mentions to roles and custom emoji are not rendered properly. We need
  to maintain our own DB.
* We might not need the "attachments" leaf package anymore? It's just
  there to avoid an import cycle.

Bridging actual events (i.e. wiring up discordgo's event handlers) is
probably next.
This commit is contained in:
Skip R
2025-11-26 18:09:00 -08:00
parent 86e18c1f7d
commit b5e6db06f8
10 changed files with 629 additions and 111 deletions

View File

@@ -0,0 +1,38 @@
// 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 <https://www.gnu.org/licenses/>.
package attachment
import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// TODO(skip): These types are only in a leaf package to avoid import cycles.
// Perhaps figure out a better way to structure this so that this package is unnecessary.
type AttachmentReupload struct {
DownloadingURL string
FileName string
MimeType string
}
type ReuploadedAttachment struct {
AttachmentReupload
DownloadedSize int
MXC id.ContentURIString
EncryptedFile *event.EncryptedFileInfo
}

View File

@@ -28,9 +28,8 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/attachment"
"maunium.net/go/mautrix/bridgev2" "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) { func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
@@ -69,20 +68,7 @@ func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]b
} }
} }
type AttachmentReupload struct { func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) {
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) log := zerolog.Ctx(ctx)
// TODO(skip): Do we need to check if we've already downloaded this media before? // TODO(skip): Do we need to check if we've already downloaded this media before?
// TODO(skip): Read a maximum size from the config. // TODO(skip): Read a maximum size from the config.
@@ -117,7 +103,7 @@ func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.Ma
return nil, err return nil, err
} }
return &ReuploadedAttachment{ return &attachment.ReuploadedAttachment{
AttachmentReupload: upload, AttachmentReupload: upload,
DownloadedSize: len(data), DownloadedSize: len(data),
MXC: mxc, MXC: mxc,

View File

@@ -23,9 +23,9 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/msgconv"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
) )
var ( var (
@@ -65,15 +65,33 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
} }
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs)) converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
mc := msgconv.MessageConverter{
Bridge: dc.connector.Bridge,
ReuploadMedia: dc.connector.ReuploadMedia,
}
for _, msg := range msgs { for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64) streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID) ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
// FIXME(skip): Backfill reactions. // FIXME(skip): Backfill reactions.
sender := dc.makeEventSender(msg.Author)
// Use the ghost's intent, falling back to the bridge's.
ghost, err := dc.connector.Bridge.GetGhostByID(ctx, sender.Sender)
if err != nil {
log.Err(err).Msg("Failed to look up ghost while converting backfilled message")
}
var intent bridgev2.MatrixAPI
if ghost == nil {
intent = fetchParams.Portal.Bridge.Bot
} else {
intent = ghost.Intent
}
converted = append(converted, &bridgev2.BackfillMessage{ converted = append(converted, &bridgev2.BackfillMessage{
ConvertedMessage: dc.convertMessage(msg),
ID: networkid.MessageID(msg.ID), ID: networkid.MessageID(msg.ID),
Sender: dc.makeEventSender(msg.Author), ConvertedMessage: mc.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg),
Sender: sender,
Timestamp: ts, Timestamp: ts,
StreamOrder: streamOrder, StreamOrder: streamOrder,
}) })
@@ -91,43 +109,3 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
HasMore: len(msgs) == count, HasMore: len(msgs) == count,
}, nil }, nil
} }
func (dc *DiscordClient) convertMessage(msg *discordgo.Message) *bridgev2.ConvertedMessage {
// FIXME(skip): This isn't bridging a lot of things (replies, forwards, voice messages, attachments, webhooks, embeds, etc.). Copy from main branch.
var parts []*bridgev2.ConvertedMessagePart
switch msg.Type {
case discordgo.MessageTypeCall:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "started a call",
},
})
case discordgo.MessageTypeGuildMemberJoin:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgEmote,
Body: "joined the server",
},
})
}
if msg.Content != "" {
// FIXME(skip): This needs to render into HTML.
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: msg.Content,
},
})
}
return &bridgev2.ConvertedMessage{
// TODO(skip): Replies.
Parts: parts,
}
}

View File

@@ -27,6 +27,7 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordid"
"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"
@@ -221,6 +222,14 @@ func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar {
} }
} }
func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: user.ID == d.Session.State.User.ID,
SenderLogin: networkid.UserLoginID(user.ID),
Sender: networkid.UserID(user.ID),
}
}
func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) {
isGroup := len(ch.RecipientIDs) > 1 isGroup := len(ch.RecipientIDs) > 1
@@ -253,7 +262,7 @@ func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, se
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
channel: ch, channel: ch,
portalKey: MakePortalKey(ch, d.UserLogin.ID, true), portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true),
info: &bridgev2.ChatInfo{ info: &bridgev2.ChatInfo{
Name: &ch.Name, Name: &ch.Name,
Members: &members, Members: &members,

View File

@@ -14,11 +14,10 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector package discordid
import ( import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
) )
@@ -34,11 +33,3 @@ func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = networkid.PortalID(channelID) key.ID = networkid.PortalID(channelID)
return return
} }
func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: user.ID == d.Session.State.User.ID,
SenderLogin: networkid.UserLoginID(user.ID),
Sender: networkid.UserID(user.ID),
}
}

99
pkg/msgconv/formatter.go Normal file
View File

@@ -0,0 +1,99 @@
// 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 <https://www.gnu.org/licenses/>.
package msgconv
import (
"fmt"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext"
)
// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
//
// Discord allows escaping with just one backslash, e.g. \__a__,
// but standard markdown requires both to be escaped (\_\_a__)
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
func escapeReplacement(s string) string {
return s[:2] + `\` + s[2:]
}
// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
type indentableParagraphParser struct {
parser.BlockParser
}
var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
return true
}
var removeFeaturesExceptLinks = []any{
parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
parser.NewCodeBlockParser(),
}
var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
var discordRenderer = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
var discordRendererWithInlineLinks = goldmark.New(
goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
)
// renderDiscordMarkdownOnlyHTML converts Discord-flavored Markdown text to HTML.
//
// After conversion, if the text is surrounded by a single outermost paragraph
// tag, it is unwrapped.
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
return format.UnwrapSingleParagraph(mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, text, allowInlineLinks))
}
// renderDiscordMarkdownOnlyHTMLNoUnwrap converts Discord-flavored Markdown text to HTML.
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridgev2.Portal, text string, allowInlineLinks bool) string {
text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
var buf strings.Builder
ctx := parser.NewContext()
ctx.Set(parserContextPortal, portal)
renderer := discordRenderer
if allowInlineLinks {
renderer = discordRendererWithInlineLinks
}
err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
if err != nil {
panic(fmt.Errorf("markdown parser errored: %w", err))
}
return buf.String()
}
// TODO(skip): Stopping here for now. Continue at formatterContextPortalKey.

View File

@@ -0,0 +1,110 @@
// 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 <https://www.gnu.org/licenses/>.
package msgconv
import (
"fmt"
"regexp"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type astDiscordEveryone struct {
ast.BaseInline
onlyHere bool
}
var _ ast.Node = (*astDiscordEveryone)(nil)
var astKindDiscordEveryone = ast.NewNodeKind("DiscordEveryone")
func (n *astDiscordEveryone) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
func (n *astDiscordEveryone) Kind() ast.NodeKind {
return astKindDiscordEveryone
}
func (n *astDiscordEveryone) String() string {
if n.onlyHere {
return "@here"
}
return "@everyone"
}
type discordEveryoneParser struct{}
var discordEveryoneRegex = regexp.MustCompile(`@(everyone|here)`)
var defaultDiscordEveryoneParser = &discordEveryoneParser{}
func (s *discordEveryoneParser) Trigger() []byte {
return []byte{'@'}
}
func (s *discordEveryoneParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
match := discordEveryoneRegex.FindSubmatch(line)
if match == nil {
return nil
}
block.Advance(len(match[0]))
return &astDiscordEveryone{
onlyHere: string(match[1]) == "here",
}
}
func (s *discordEveryoneParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordEveryoneHTMLRenderer struct{}
func (r *discordEveryoneHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordEveryone, r.renderDiscordEveryone)
}
func (r *discordEveryoneHTMLRenderer) renderDiscordEveryone(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
status = ast.WalkContinue
if !entering {
return
}
mention, _ := n.(*astDiscordEveryone)
class := "everyone"
if mention != nil && mention.onlyHere {
class = "here"
}
_, _ = fmt.Fprintf(w, `<span class="discord-mention-%s">@room</span>`, class)
return
}
type discordEveryone struct{}
var ExtDiscordEveryone = &discordEveryone{}
func (e *discordEveryone) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordEveryoneParser, 600),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&discordEveryoneHTMLRenderer{}, 600),
))
}

View File

@@ -16,7 +16,77 @@
package msgconv package msgconv
// TODO(skip): Port the rest of this. import (
"context"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"go.mau.fi/mautrix-discord/pkg/discordid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
)
type astDiscordTag struct {
ast.BaseInline
portal *bridgev2.Portal
id int64
}
var _ ast.Node = (*astDiscordTag)(nil)
var astKindDiscordTag = ast.NewNodeKind("DiscordTag")
func (n *astDiscordTag) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
func (n *astDiscordTag) Kind() ast.NodeKind {
return astKindDiscordTag
}
type astDiscordUserMention struct {
astDiscordTag
hasNick bool
}
func (n *astDiscordUserMention) String() string {
if n.hasNick {
return fmt.Sprintf("<@!%d>", n.id)
}
return fmt.Sprintf("<@%d>", n.id)
}
type astDiscordRoleMention struct {
astDiscordTag
}
func (n *astDiscordRoleMention) String() string {
return fmt.Sprintf("<@&%d>", n.id)
}
type astDiscordChannelMention struct {
astDiscordTag
guildID int64
name string
}
func (n *astDiscordChannelMention) String() string {
if n.guildID != 0 {
return fmt.Sprintf("<#%d:%d:%s>", n.id, n.guildID, n.name)
}
return fmt.Sprintf("<#%d>", n.id)
}
type discordTimestampStyle rune type discordTimestampStyle rune
@@ -38,3 +108,237 @@ func (dts discordTimestampStyle) Format() string {
return "2 January 2006 15:04 MST" return "2 January 2006 15:04 MST"
} }
} }
type astDiscordTimestamp struct {
astDiscordTag
timestamp int64
style discordTimestampStyle
}
func (n *astDiscordTimestamp) String() string {
if n.style == 'f' {
return fmt.Sprintf("<t:%d>", n.timestamp)
}
return fmt.Sprintf("<t:%d:%c>", n.timestamp, n.style)
}
type astDiscordCustomEmoji struct {
astDiscordTag
name string
animated bool
}
func (n *astDiscordCustomEmoji) String() string {
if n.animated {
return fmt.Sprintf("<a%s%d>", n.name, n.id)
}
return fmt.Sprintf("<%s%d>", n.name, n.id)
}
type discordTagParser struct{}
// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting
var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`)
var defaultDiscordTagParser = &discordTagParser{}
func (s *discordTagParser) Trigger() []byte {
return []byte{'<'}
}
var parserContextPortal = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
portal := pc.Get(parserContextPortal).(*bridgev2.Portal)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
if match == nil {
return nil
}
//seg := segment.WithStop(segment.Start + len(match[0]))
block.Advance(len(match[0]))
id, err := strconv.ParseInt(string(match[2]), 10, 64)
if err != nil {
return nil
}
tag := astDiscordTag{id: id, portal: portal}
tagName := string(match[1])
switch {
case tagName == "@":
return &astDiscordUserMention{astDiscordTag: tag}
case tagName == "@!":
return &astDiscordUserMention{astDiscordTag: tag, hasNick: true}
case tagName == "@&":
return &astDiscordRoleMention{astDiscordTag: tag}
case tagName == "#":
var guildID int64
var channelName string
if len(match[4]) > 0 && len(match[5]) > 0 {
guildID, _ = strconv.ParseInt(string(match[4]), 10, 64)
channelName = string(match[5])
}
return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName}
case tagName == "t:":
var style discordTimestampStyle
if len(match[3]) == 0 {
style = 'f'
} else {
style = discordTimestampStyle(match[3][0])
}
return &astDiscordTimestamp{
astDiscordTag: tag,
timestamp: id,
style: style,
}
case strings.HasPrefix(tagName, ":"):
return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
case strings.HasPrefix(tagName, "a:"):
return &astDiscordCustomEmoji{name: tagName[1:], astDiscordTag: tag, animated: true}
default:
return nil
}
}
func (s *discordTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type discordTagHTMLRenderer struct{}
var defaultDiscordTagHTMLRenderer = &discordTagHTMLRenderer{}
func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindDiscordTag, r.renderDiscordMention)
}
func relativeTimeFormat(ts time.Time) string {
now := time.Now()
if ts.Year() >= 2262 {
return "date out of range for relative format"
}
duration := ts.Sub(now)
word := "in %s"
if duration < 0 {
duration = -duration
word = "%s ago"
}
var count int
var unit string
switch {
case duration < time.Second:
count = int(duration.Milliseconds())
unit = "millisecond"
case duration < time.Minute:
count = int(math.Round(duration.Seconds()))
unit = "second"
case duration < time.Hour:
count = int(math.Round(duration.Minutes()))
unit = "minute"
case duration < 24*time.Hour:
count = int(math.Round(duration.Hours()))
unit = "hour"
case duration < 30*24*time.Hour:
count = int(math.Round(duration.Hours() / 24))
unit = "day"
case duration < 365*24*time.Hour:
count = int(math.Round(duration.Hours() / 24 / 30))
unit = "month"
default:
count = int(math.Round(duration.Hours() / 24 / 365))
unit = "year"
}
var diff string
if count == 1 {
diff = fmt.Sprintf("a %s", unit)
} else {
diff = fmt.Sprintf("%d %ss", count, unit)
}
return fmt.Sprintf(word, diff)
}
func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
status = ast.WalkContinue
if !entering {
return
}
ctx := context.TODO()
switch node := n.(type) {
case *astDiscordUserMention:
var mxid id.UserID
var name string
if ghost, _ := node.portal.Bridge.GetGhostByID(ctx, networkid.UserID(strconv.FormatInt(node.id, 10))); ghost != nil {
mxid = ghost.Intent.GetMXID()
name = ghost.Name
}
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), name)
return
case *astDiscordRoleMention:
// FIXME(skip): Implement.
// role := node.portal.Bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
// if role != nil {
_, _ = fmt.Fprintf(w, `<strong>@unknown-role</strong>`)
// _, _ = fmt.Fprintf(w, `<font color="#%06x"><strong>@%s</strong></font>`, role.Color, role.Name)
return
// }
case *astDiscordChannelMention:
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(
strconv.FormatInt(node.id, 10),
)); portal != nil {
if portal.MXID != "" {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, portal.MXID.URI(portal.Bridge.Matrix.ServerName()).MatrixToURL(), portal.Name)
} else {
_, _ = w.WriteString(portal.Name)
}
return
}
case *astDiscordCustomEmoji:
// FIXME(skip): Implement.
_, _ = fmt.Fprintf(w, `(emoji)`)
// reactionMXC := node.portal.Bridge.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
// if !reactionMXC.IsEmpty() {
// attrs := "data-mx-emoticon"
// if node.animated {
// attrs += " data-mau-animated-emoji"
// }
// _, _ = fmt.Fprintf(w, `<img %[3]s src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name, attrs)
// return
// }
case *astDiscordTimestamp:
ts := time.Unix(node.timestamp, 0).UTC()
var formatted string
if node.style == 'R' {
formatted = relativeTimeFormat(ts)
} else {
formatted = ts.Format(node.style.Format())
}
// https://github.com/matrix-org/matrix-spec-proposals/pull/3160
const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
fullRFC := ts.Format(fullDatetimeFormat)
fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s" data-discord-style="%c"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, node.style, formatted)
}
stringifiable, ok := n.(fmt.Stringer)
if ok {
_, _ = w.WriteString(stringifiable.String())
} else {
_, _ = w.Write(source)
}
return
}
type discordTag struct{}
var ExtDiscordTag = &discordTag{}
func (e *discordTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(defaultDiscordTagParser, 600),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(defaultDiscordTagHTMLRenderer, 600),
))
}

View File

@@ -26,7 +26,8 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/connector" "go.mau.fi/mautrix-discord/pkg/attachment"
"go.mau.fi/mautrix-discord/pkg/discordid"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@@ -111,6 +112,11 @@ func (mc *MessageConverter) ToMatrix(
// puppet.addMemberMeta(part, msg) // puppet.addMemberMeta(part, msg)
// } // }
// Assign incrementing part IDs.
for i, part := range parts {
part.ID = networkid.PartID(strconv.Itoa(i))
}
return &bridgev2.ConvertedMessage{Parts: parts} return &bridgev2.ConvertedMessage{Parts: parts}
} }
@@ -143,7 +149,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent
var htmlParts []string var htmlParts []string
if msg.Interaction != nil { if msg.Interaction != nil {
ghost, err := mc.connector.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID)) ghost, err := mc.Bridge.GetGhostByID(ctx, networkid.UserID(msg.Interaction.User.ID))
// TODO(skip): Try doing ghost.UpdateInfoIfNecessary. // TODO(skip): Try doing ghost.UpdateInfoIfNecessary.
if err == nil { if err == nil {
htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name)) htmlParts = append(htmlParts, fmt.Sprintf(msgInteractionTemplateHTML, ghost.Intent.GetMXID(), ghost.Name, msg.Interaction.Name))
@@ -154,22 +160,22 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent
if msg.Content != "" && !isPlainGifMessage(msg) { if msg.Content != "" && !isPlainGifMessage(msg) {
// Bridge basic text messages. // Bridge basic text messages.
htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(msg.Content, true)) htmlParts = append(htmlParts, mc.renderDiscordMarkdownOnlyHTML(portal, msg.Content, true))
} else if msg.MessageReference != nil && } else if msg.MessageReference != nil &&
msg.MessageReference.Type == discordgo.MessageReferenceTypeForward && msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
len(msg.MessageSnapshots) > 0 && len(msg.MessageSnapshots) > 0 &&
msg.MessageSnapshots[0].Message != nil { msg.MessageSnapshots[0].Message != nil {
// Bridge forwarded messages. // Bridge forwarded messages.
forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(msg.MessageSnapshots[0].Message.Content, true) forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true)
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST") msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
origLink := fmt.Sprintf("unknown channel • %s", msgTSText) origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
if forwardedFromPortal, err := mc.connector.Bridge.DB.Portal.GetByKey(ctx, connector.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil { if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.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 { if origMessage, err := mc.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. // We've bridged the message that was forwarded, so we can link to it directly.
origLink = fmt.Sprintf( origLink = fmt.Sprintf(
`<a href="%s">#%s • %s</a>`, `<a href="%s">#%s • %s</a>`,
forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name, forwardedFromPortal.Name,
msgTSText, msgTSText,
) )
@@ -179,7 +185,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent
// We don't have the message but we have the portal, so link to that. // We don't have the message but we have the portal, so link to that.
origLink = fmt.Sprintf( origLink = fmt.Sprintf(
`<a href="%s">#%s</a> • %s`, `<a href="%s">#%s</a> • %s`,
forwardedFromPortal.MXID.URI(mc.connector.Bridge.Matrix.ServerName()), forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name, forwardedFromPortal.Name,
msgTSText, msgTSText,
) )
@@ -264,10 +270,10 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent
} }
} }
upload := connector.AttachmentReupload{ upload := attachment.AttachmentReupload{
DownloadingURL: proxyURL, DownloadingURL: proxyURL,
} }
reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, upload) reupload, err := mc.ReuploadMedia(ctx, intent, portal, upload)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix") zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
return &bridgev2.ConvertedMessagePart{ return &bridgev2.ConvertedMessagePart{
@@ -282,10 +288,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent
MimeType: reupload.MimeType, MimeType: reupload.MimeType,
Size: reupload.DownloadedSize, Size: reupload.DownloadedSize,
}, },
File: &event.EncryptedFileInfo{ File: reupload.EncryptedFile,
EncryptedFile: reupload.EncryptedFile.EncryptedFile,
URL: reupload.MXC,
},
} }
if embed.Video != nil { if embed.Video != nil {
@@ -321,14 +324,6 @@ func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent
panic("unimplemented") panic("unimplemented")
} }
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
panic("unimplemented")
}
func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string {
panic("unimplemented")
}
const ( const (
embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>` embedHTMLWrapper = `<blockquote class="discord-embed">%s</blockquote>`
embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>` embedHTMLWrapperColor = `<blockquote class="discord-embed" background-color="#%06X">%s</blockquote>`
@@ -361,7 +356,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
} }
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML) authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
if embed.Author.ProxyIconURL != "" { if embed.Author.ProxyIconURL != "" {
reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: embed.Author.ProxyIconURL, DownloadingURL: embed.Author.ProxyIconURL,
}) })
@@ -376,7 +371,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
if embed.Title != "" { if embed.Title != "" {
var titleHTML string var titleHTML string
baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(embed.Title, false) baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false)
if embed.URL != "" { if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML) titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else { } else {
@@ -386,7 +381,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
} }
if embed.Description != "" { if embed.Description != "" {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(embed.Description, true))) htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLDescription, mc.renderDiscordMarkdownOnlyHTML(portal, embed.Description, true)))
} }
for i := 0; i < len(embed.Fields); i++ { for i := 0; i < len(embed.Fields); i++ {
@@ -405,21 +400,21 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
headerParts := make([]string, len(splitItems)) headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems)) contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems { for j, splitItem := range splitItems {
headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(splitItem.Name, false)) headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false))
contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(splitItem.Value, true)) contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true))
} }
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, ""))) htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else { } else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField, htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline), strconv.FormatBool(item.Inline),
mc.renderDiscordMarkdownOnlyHTML(item.Name, false), mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false),
mc.renderDiscordMarkdownOnlyHTML(item.Value, true), mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true),
)) ))
} }
} }
if embed.Image != nil { if embed.Image != nil {
reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: embed.Image.ProxyURL, DownloadingURL: embed.Image.ProxyURL,
}) })
if err != nil { if err != nil {
@@ -449,7 +444,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
} }
footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart) footerHTML = fmt.Sprintf(embedHTMLFooterPlain, html.EscapeString(embed.Footer.Text), datePart)
if embed.Footer.ProxyIconURL != "" { if embed.Footer.ProxyIconURL != "" {
reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: embed.Footer.ProxyIconURL, DownloadingURL: embed.Footer.ProxyIconURL,
}) })
@@ -478,7 +473,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
} }
func (mc *MessageConverter) renderDiscordLinkEmbedImage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, url string, width, height int, preview *event.BeeperLinkPreview) { 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{ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: url, DownloadingURL: url,
}) })
if err != nil { if err != nil {
@@ -556,7 +551,7 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent
} }
// TODO(skip): Support direct media. // TODO(skip): Support direct media.
reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: att.URL, DownloadingURL: att.URL,
}) })
if err != nil { if err != nil {
@@ -572,10 +567,8 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent
content.Info.Width = att.Width content.Info.Width = att.Width
content.Info.Height = att.Height content.Info.Height = att.Height
} }
content.File = &event.EncryptedFileInfo{ content.URL = reupload.MXC
EncryptedFile: reupload.EncryptedFile.EncryptedFile, content.File = reupload.EncryptedFile
URL: reupload.MXC,
}
return &bridgev2.ConvertedMessagePart{ return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage, Type: event.EventMessage,

View File

@@ -17,9 +17,19 @@
package msgconv package msgconv
import ( import (
"go.mau.fi/mautrix-discord/pkg/connector" "context"
"go.mau.fi/mautrix-discord/pkg/attachment"
"maunium.net/go/mautrix/bridgev2"
) )
type MessageConverter struct { type MessageConverter struct {
connector *connector.DiscordConnector Bridge *bridgev2.Bridge
// 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)
} }