diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts new file mode 100644 index 0000000000..f1ee615e7e --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock the runtime before importing resolveMentions +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => ({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((p) => p.test(text)), + }, + }, + }), +})); + +import { resolveMentions } from "./mentions.js"; + +describe("resolveMentions", () => { + const userId = "@bot:matrix.org"; + const mentionRegexes = [/@bot/i]; + + describe("m.mentions field", () => { + it("detects mention via m.mentions.user_ids", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hello", + "m.mentions": { user_ids: ["@bot:matrix.org"] }, + }, + userId, + text: "hello", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + expect(result.hasExplicitMention).toBe(true); + }); + + it("detects room mention via m.mentions.room", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hello everyone", + "m.mentions": { room: true }, + }, + userId, + text: "hello everyone", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + }); + }); + + describe("formatted_body matrix.to links", () => { + it("detects mention in formatted_body with plain user ID", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "Bot: hello", + formatted_body: 'Bot: hello', + }, + userId, + text: "Bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); + + it("detects mention in formatted_body with URL-encoded user ID", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "Bot: hello", + formatted_body: 'Bot: hello', + }, + userId, + text: "Bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); + + it("detects mention with single quotes in href", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "Bot: hello", + formatted_body: "Bot: hello", + }, + userId, + text: "Bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); + + it("does not detect mention for different user ID", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "Other: hello", + formatted_body: 'Other: hello', + }, + userId, + text: "Other: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(false); + }); + + it("does not false-positive on partial user ID match", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "Bot2: hello", + formatted_body: 'Bot2: hello', + }, + userId: "@bot:matrix.org", + text: "Bot2: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(false); + }); + }); + + describe("regex patterns", () => { + it("detects mention via regex pattern in body text", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hey @bot can you help?", + }, + userId, + text: "hey @bot can you help?", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + }); + }); + + describe("no mention", () => { + it("returns false when no mention is present", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hello world", + }, + userId, + text: "hello world", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(false); + expect(result.hasExplicitMention).toBe(false); + }); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 1053b3fa17..232e495c88 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -4,12 +4,36 @@ import { getMatrixRuntime } from "../../runtime.js"; type MessageContentWithMentions = { msgtype: string; body: string; + formatted_body?: string; "m.mentions"?: { user_ids?: string[]; room?: boolean; }; }; +/** + * Check if the formatted_body contains a matrix.to mention link for the given user ID. + * Many Matrix clients (including Element) use HTML links in formatted_body instead of + * or in addition to the m.mentions field. + */ +function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean { + if (!formattedBody || !userId) { + return false; + } + // Escape special regex characters in the user ID (e.g., @user:matrix.org) + const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Match matrix.to links with the user ID, handling both URL-encoded and plain formats + // Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org" + const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i"); + if (plainPattern.test(formattedBody)) { + return true; + } + // Also check URL-encoded version (@ -> %40, : -> %3A) + const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i"); + return encodedPattern.test(formattedBody); +} + export function resolveMentions(params: { content: MessageContentWithMentions; userId?: string | null; @@ -20,9 +44,16 @@ export function resolveMentions(params: { const mentionedUsers = Array.isArray(mentions?.user_ids) ? new Set(mentions.user_ids) : new Set(); + + // Check formatted_body for matrix.to mention links (legacy/alternative mention format) + const mentionedInFormattedBody = params.userId + ? checkFormattedBodyMention(params.content.formatted_body, params.userId) + : false; + const wasMentioned = Boolean(mentions?.room) || (params.userId ? mentionedUsers.has(params.userId) : false) || + mentionedInFormattedBody || getMatrixRuntime().channel.mentions.matchesMentionPatterns( params.text ?? "", params.mentionRegexes,