From b94402025bca224a1bea86a9d368887cc8e3e1e8 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Oct 2026 16:30:02 +0000 Subject: [PATCH] PLAT-39405 connector: turn Reddit @username text into Matrix mentions Reddit sends mentions as plain "@username" text with no m.mentions or formatted body, so they arrived in Beeper as plain text. Co-Authored-By: Claude Opus 5.5 --- pkg/connector/mentions.go | 66 ++++++++++++++++++++++++++++++++++ pkg/connector/mentions_test.go | 34 ++++++++++++++++++ pkg/connector/tomatrix.go | 3 ++ 3 files changed, 103 insertions(+) create mode 100644 pkg/connector/mentions.go create mode 100644 pkg/connector/mentions_test.go diff --git a/pkg/connector/mentions.go b/pkg/connector/mentions.go new file mode 100644 index 0000000..ca4681e --- /dev/null +++ b/pkg/connector/mentions.go @@ -0,0 +1,66 @@ +package connector + +import ( + "fmt" + "html" + "regexp" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var mentionPattern = regexp.MustCompile(`(?:^|[^\w@/])(@[\w-]{3,20})`) + +// Reddit sends mentions as plain "@username" text with no Matrix mention data. +func (r *RedditClient) convertMentions(portal *bridgev2.Portal, content *event.MessageEventContent) { + meta, _ := portal.Metadata.(*PortalMetadata) + if content.Format != "" || meta == nil || meta.State == nil { + return + } + formatted, mentioned := linkMentions(content.Body, meta.State.Members, r.beeperMXID) + if len(mentioned) == 0 { + return + } + content.Format = event.FormatHTML + content.FormattedBody = formatted + content.Mentions = &event.Mentions{UserIDs: mentioned} +} + +func (r *RedditClient) beeperMXID(redditMXID id.UserID) id.UserID { + if userID := makeUserID(redditMXID); userID != r.userID { + return r.main.Bridge.Matrix.FormatGhostMXID(userID) + } + return r.userLogin.UserMXID +} + +func linkMentions(body string, members map[id.UserID]RoomMember, beeperMXID func(id.UserID) id.UserID) (string, []id.UserID) { + usernames := make(map[string]id.UserID, len(members)) + for mxid, member := range members { + if member.Name != "" { + usernames[strings.ToLower(stripUserPrefix(member.Name))] = mxid + } + } + var formatted strings.Builder + var mentioned []id.UserID + last := 0 + for _, match := range mentionPattern.FindAllStringSubmatchIndex(body, -1) { + start, end := match[2], match[3] + redditMXID, ok := usernames[strings.ToLower(body[start+1:end])] + if !ok { + continue + } + target := beeperMXID(redditMXID) + formatted.WriteString(plainToHTML(body[last:start])) + _, _ = fmt.Fprintf(&formatted, `%s`, target.URI().MatrixToURL(), html.EscapeString(body[start:end])) + mentioned = append(mentioned, target) + last = end + } + formatted.WriteString(plainToHTML(body[last:])) + return formatted.String(), mentioned +} + +func plainToHTML(text string) string { + return strings.ReplaceAll(html.EscapeString(text), "\n", "
") +} diff --git a/pkg/connector/mentions_test.go b/pkg/connector/mentions_test.go new file mode 100644 index 0000000..29fd19b --- /dev/null +++ b/pkg/connector/mentions_test.go @@ -0,0 +1,34 @@ +package connector + +import ( + "slices" + "testing" + + "maunium.net/go/mautrix/id" +) + +func TestLinkMentions(t *testing.T) { + members := map[id.UserID]RoomMember{ + "@t2_a:reddit.com": {Name: "Some_User-1"}, + "@t2_b:reddit.com": {Name: "other"}, + "@t2_c:reddit.com": {}, + } + beeperMXID := func(mxid id.UserID) id.UserID { return "@ghost_" + id.UserID(makeUserID(mxid)) + ":beeper.local" } + for _, tc := range []struct { + name, body, want string + mentioned []id.UserID + }{ + {"start", "@some_user-1 hi", `@some_user-1 hi`, []id.UserID{"@ghost_t2_a:beeper.local"}}, + {"two", "hey @other & @Some_User-1!", `hey @other & @Some_User-1!`, []id.UserID{"@ghost_t2_b:beeper.local", "@ghost_t2_a:beeper.local"}}, + {"not a member", "@stranger ", "@stranger <b>", nil}, + {"email and url", "a@other.com reddit.com/@other", "a@other.com reddit.com/@other", nil}, + {"newline", "line\n@other", `line
@other`, []id.UserID{"@ghost_t2_b:beeper.local"}}, + } { + t.Run(tc.name, func(t *testing.T) { + formatted, mentioned := linkMentions(tc.body, members, beeperMXID) + if formatted != tc.want || !slices.Equal(mentioned, tc.mentioned) { + t.Fatalf("linkMentions(%q) = %q, %v", tc.body, formatted, mentioned) + } + }) + } +} diff --git a/pkg/connector/tomatrix.go b/pkg/connector/tomatrix.go index 22abddf..f8f7bcc 100644 --- a/pkg/connector/tomatrix.go +++ b/pkg/connector/tomatrix.go @@ -47,6 +47,9 @@ func (r *RedditClient) convertMessage(ctx context.Context, portal *bridgev2.Port return nil, err } case event.MsgText, event.MsgNotice, event.MsgEmote: + if portal != nil { + r.convertMentions(portal, &content) + } default: return nil, bridgev2.ErrUnsupportedMessageType }