diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index c6afad3681d..0e264a46b51 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -194,6 +194,21 @@ describe("resolveMentions", () => { expect(result.wasMentioned).toBe(true); }); + it("detects mention when the visible label encodes the bot's displayName", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "R&D Bot: hello", + formatted_body: 'R&D Bot: hello', + }, + userId, + displayName: "R&D Bot", + text: "R&D Bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); + it("does not detect mention when displayName is spoofed", () => { const result = resolveMentions({ content: { diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 70739f446d4..f1388de2652 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,10 +1,34 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { RoomMessageEventContent } from "./types.js"; +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeHtmlEntities(value: string): string { + return value.replace(/&(#x?[0-9a-f]+|\w+);/gi, (match, entity: string) => { + const normalized = entity.toLowerCase(); + if (normalized.startsWith("#x")) { + const codePoint = Number.parseInt(normalized.slice(2), 16); + return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint); + } + if (normalized.startsWith("#")) { + const codePoint = Number.parseInt(normalized.slice(1), 10); + return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint); + } + return HTML_ENTITY_REPLACEMENTS[normalized] ?? match; + }); +} + function normalizeVisibleMentionText(value: string): string { - return value - .replace(/<[^>]+>/g, " ") - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + return decodeHtmlEntities( + value.replace(/<[^>]+>/g, " ").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, ""), + ) .replace(/\s+/g, " ") .trim() .toLowerCase(); @@ -41,13 +65,11 @@ function isVisibleMentionLabel(params: { } const localpart = resolveMatrixUserLocalpart(params.userId); const candidates = [ - params.userId.trim().toLowerCase(), - localpart, - localpart ? `@${localpart}` : null, + extractVisibleMentionText(params.userId), + localpart ? extractVisibleMentionText(localpart) : null, + localpart ? extractVisibleMentionText(`@${localpart}`) : null, params.displayName ? extractVisibleMentionText(params.displayName) : null, - ] - .filter((value): value is string => Boolean(value)) - .map((value) => value.toLowerCase()); + ].filter((value): value is string => Boolean(value)); return candidates.includes(cleaned); } diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts index 0cfb3c4ab1c..f3ffb1768b3 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.test.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -45,6 +45,51 @@ describe("createMatrixRoomInfoResolver", () => { expect(client.getRoomStateEvent).toHaveBeenCalledTimes(3); }); + it("caches fallback user IDs when member display names are missing", async () => { + const client = { + getRoomStateEvent: vi.fn( + async (_roomId: string, eventType: string): Promise> => { + if (eventType === "m.room.member") { + return {}; + } + return {}; + }, + ), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; + const resolver = createMatrixRoomInfoResolver(client); + + await expect( + resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"), + ).resolves.toBe("@alice:example.org"); + await expect( + resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"), + ).resolves.toBe("@alice:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(1); + }); + + it("caches fallback user IDs when member display-name lookups fail", async () => { + const client = { + getRoomStateEvent: vi.fn(async (): Promise> => { + throw new Error("member lookup failed"); + }), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; + const resolver = createMatrixRoomInfoResolver(client); + + await expect( + resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"), + ).resolves.toBe("@alice:example.org"); + await expect( + resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"), + ).resolves.toBe("@alice:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(1); + }); + it("bounds cached room and member entries", async () => { const client = createClientStub(); const resolver = createMatrixRoomInfoResolver(client); diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index cbfc4b173b5..07af0df1a0f 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -83,27 +83,21 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { const getMemberDisplayName = async (roomId: string, userId: string): Promise => { const cacheKey = `${roomId}:${userId}`; - const cached = memberDisplayNameCache.get(cacheKey); - if (cached) { - return cached; - } - try { - const memberState = await client - .getRoomStateEvent(roomId, "m.room.member", userId) - .catch(() => null); - if (memberState && typeof memberState.displayname === "string") { - rememberBounded( - memberDisplayNameCache, - cacheKey, - memberState.displayname, - MAX_TRACKED_MEMBER_DISPLAY_NAMES, - ); - return memberState.displayname; - } - return userId; - } catch { - return userId; + if (memberDisplayNameCache.has(cacheKey)) { + return memberDisplayNameCache.get(cacheKey) ?? userId; } + const memberState = await client + .getRoomStateEvent(roomId, "m.room.member", userId) + .catch(() => null); + const displayName = + memberState && typeof memberState.displayname === "string" ? memberState.displayname : userId; + rememberBounded( + memberDisplayNameCache, + cacheKey, + displayName, + MAX_TRACKED_MEMBER_DISPLAY_NAMES, + ); + return displayName; }; return {