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, `%[2]s`, 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) }