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,