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 {