diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go
new file mode 100644
index 0000000..3e573cb
--- /dev/null
+++ b/pkg/attachment/attachment.go
@@ -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 .
+
+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
+}
diff --git a/pkg/connector/attachments.go b/pkg/connector/attachments.go
index 69d3b55..eada633 100644
--- a/pkg/connector/attachments.go
+++ b/pkg/connector/attachments.go
@@ -28,9 +28,8 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
+ "go.mau.fi/mautrix-discord/pkg/attachment"
"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) {
@@ -69,20 +68,7 @@ func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]b
}
}
-type AttachmentReupload struct {
- 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) {
+func (d *DiscordConnector) ReuploadMedia(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, upload attachment.AttachmentReupload) (*attachment.ReuploadedAttachment, error) {
log := zerolog.Ctx(ctx)
// TODO(skip): Do we need to check if we've already downloaded this media before?
// 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 &ReuploadedAttachment{
+ return &attachment.ReuploadedAttachment{
AttachmentReupload: upload,
DownloadedSize: len(data),
MXC: mxc,
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
index e76b26f..99dee51 100644
--- a/pkg/connector/backfill.go
+++ b/pkg/connector/backfill.go
@@ -23,9 +23,9 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
+ "go.mau.fi/mautrix-discord/pkg/msgconv"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
- "maunium.net/go/mautrix/event"
)
var (
@@ -65,15 +65,33 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
}
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
+ mc := msgconv.MessageConverter{
+ Bridge: dc.connector.Bridge,
+ ReuploadMedia: dc.connector.ReuploadMedia,
+ }
for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
// 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{
- ConvertedMessage: dc.convertMessage(msg),
ID: networkid.MessageID(msg.ID),
- Sender: dc.makeEventSender(msg.Author),
+ ConvertedMessage: mc.ToMatrix(ctx, fetchParams.Portal, intent, dc.UserLogin, msg),
+ Sender: sender,
Timestamp: ts,
StreamOrder: streamOrder,
})
@@ -91,43 +109,3 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
HasMore: len(msgs) == count,
}, 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,
- }
-}
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index ac04139..f43b115 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -27,6 +27,7 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"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) {
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{
channel: ch,
- portalKey: MakePortalKey(ch, d.UserLogin.ID, true),
+ portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true),
info: &bridgev2.ChatInfo{
Name: &ch.Name,
Members: &members,
diff --git a/pkg/connector/id.go b/pkg/discordid/id.go
similarity index 79%
rename from pkg/connector/id.go
rename to pkg/discordid/id.go
index 5519ddd..e329aa2 100644
--- a/pkg/connector/id.go
+++ b/pkg/discordid/id.go
@@ -14,11 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package connector
+package discordid
import (
"github.com/bwmarrin/discordgo"
- "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
)
@@ -34,11 +33,3 @@ func MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = networkid.PortalID(channelID)
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),
- }
-}
diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go
new file mode 100644
index 0000000..7065bc6
--- /dev/null
+++ b/pkg/msgconv/formatter.go
@@ -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 .
+
+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.
diff --git a/pkg/msgconv/formatter_everyone.go b/pkg/msgconv/formatter_everyone.go
new file mode 100644
index 0000000..8e87013
--- /dev/null
+++ b/pkg/msgconv/formatter_everyone.go
@@ -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 .
+
+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, `@room`, 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),
+ ))
+}
diff --git a/pkg/msgconv/formatter_tag.go b/pkg/msgconv/formatter_tag.go
index b03bf19..3252a8f 100644
--- a/pkg/msgconv/formatter_tag.go
+++ b/pkg/msgconv/formatter_tag.go
@@ -16,7 +16,77 @@
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
@@ -38,3 +108,237 @@ func (dts discordTimestampStyle) Format() string {
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("", n.timestamp)
+ }
+ return fmt.Sprintf("", n.timestamp, n.style)
+}
+
+type astDiscordCustomEmoji struct {
+ astDiscordTag
+ name string
+ animated bool
+}
+
+func (n *astDiscordCustomEmoji) String() string {
+ if n.animated {
+ return fmt.Sprintf("", 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, `%s`, 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, `@unknown-role`)
+ // _, _ = fmt.Fprintf(w, `@%s`, 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, `%s`, 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, `
`, 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, ``, 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),
+ ))
+}
diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go
index 66cbb4f..63156b3 100644
--- a/pkg/msgconv/from-discord.go
+++ b/pkg/msgconv/from-discord.go
@@ -26,7 +26,8 @@ import (
"github.com/bwmarrin/discordgo"
"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/networkid"
"maunium.net/go/mautrix/event"
@@ -111,6 +112,11 @@ func (mc *MessageConverter) ToMatrix(
// 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}
}
@@ -143,7 +149,7 @@ func (mc *MessageConverter) renderDiscordTextMessage(ctx context.Context, intent
var htmlParts []string
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.
if err == nil {
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) {
// 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 &&
msg.MessageReference.Type == discordgo.MessageReferenceTypeForward &&
len(msg.MessageSnapshots) > 0 &&
msg.MessageSnapshots[0].Message != nil {
// 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")
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 origMessage, err := mc.connector.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, networkid.MessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil {
+ if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != 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.
origLink = fmt.Sprintf(
`#%s • %s`,
- forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.connector.Bridge.Matrix.ServerName()),
+ forwardedFromPortal.MXID.EventURI(origMessage.MXID, mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name,
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.
origLink = fmt.Sprintf(
`#%s • %s`,
- forwardedFromPortal.MXID.URI(mc.connector.Bridge.Matrix.ServerName()),
+ forwardedFromPortal.MXID.URI(mc.Bridge.Matrix.ServerName()),
forwardedFromPortal.Name,
msgTSText,
)
@@ -264,10 +270,10 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent
}
}
- upload := connector.AttachmentReupload{
+ upload := attachment.AttachmentReupload{
DownloadingURL: proxyURL,
}
- reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, upload)
+ reupload, err := mc.ReuploadMedia(ctx, intent, portal, upload)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy video embed to Matrix")
return &bridgev2.ConvertedMessagePart{
@@ -282,10 +288,7 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, intent
MimeType: reupload.MimeType,
Size: reupload.DownloadedSize,
},
- File: &event.EncryptedFileInfo{
- EncryptedFile: reupload.EncryptedFile.EncryptedFile,
- URL: reupload.MXC,
- },
+ File: reupload.EncryptedFile,
}
if embed.Video != nil {
@@ -321,14 +324,6 @@ func (mc *MessageConverter) renderDiscordSticker(context context.Context, intent
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 (
embedHTMLWrapper = `%s
`
embedHTMLWrapperColor = `%s
`
@@ -361,7 +356,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
}
authorHTML = fmt.Sprintf(embedHTMLAuthorPlain, authorNameHTML)
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,
})
@@ -376,7 +371,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
if embed.Title != "" {
var titleHTML string
- baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(embed.Title, false)
+ baseTitleHTML := mc.renderDiscordMarkdownOnlyHTML(portal, embed.Title, false)
if embed.URL != "" {
titleHTML = fmt.Sprintf(embedHTMLTitleWithLink, html.EscapeString(embed.URL), baseTitleHTML)
} else {
@@ -386,7 +381,7 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
}
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++ {
@@ -405,21 +400,21 @@ func (mc *MessageConverter) renderDiscordRichEmbed(ctx context.Context, intent b
headerParts := make([]string, len(splitItems))
contentParts := make([]string, len(splitItems))
for j, splitItem := range splitItems {
- headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(splitItem.Name, false))
- contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(splitItem.Value, true))
+ headerParts[j] = fmt.Sprintf(embedHTMLFieldName, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Name, false))
+ contentParts[j] = fmt.Sprintf(embedHTMLFieldValue, mc.renderDiscordMarkdownOnlyHTML(portal, splitItem.Value, true))
}
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLFields, strings.Join(headerParts, ""), strings.Join(contentParts, "")))
} else {
htmlParts = append(htmlParts, fmt.Sprintf(embedHTMLLinearField,
strconv.FormatBool(item.Inline),
- mc.renderDiscordMarkdownOnlyHTML(item.Name, false),
- mc.renderDiscordMarkdownOnlyHTML(item.Value, true),
+ mc.renderDiscordMarkdownOnlyHTML(portal, item.Name, false),
+ mc.renderDiscordMarkdownOnlyHTML(portal, item.Value, true),
))
}
}
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,
})
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)
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,
})
@@ -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) {
- reupload, err := mc.connector.ReuploadMedia(ctx, intent, portal, connector.AttachmentReupload{
+ reupload, err := mc.ReuploadMedia(ctx, intent, portal, attachment.AttachmentReupload{
DownloadingURL: url,
})
if err != nil {
@@ -556,7 +551,7 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent
}
// 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,
})
if err != nil {
@@ -572,10 +567,8 @@ func (mc *MessageConverter) renderDiscordAttachment(ctx context.Context, intent
content.Info.Width = att.Width
content.Info.Height = att.Height
}
- content.File = &event.EncryptedFileInfo{
- EncryptedFile: reupload.EncryptedFile.EncryptedFile,
- URL: reupload.MXC,
- }
+ content.URL = reupload.MXC
+ content.File = reupload.EncryptedFile
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go
index 15237df..89c9012 100644
--- a/pkg/msgconv/msgconv.go
+++ b/pkg/msgconv/msgconv.go
@@ -17,9 +17,19 @@
package msgconv
import (
- "go.mau.fi/mautrix-discord/pkg/connector"
+ "context"
+
+ "go.mau.fi/mautrix-discord/pkg/attachment"
+ "maunium.net/go/mautrix/bridgev2"
)
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)
}