matrix: harden display-name mention matching openclaw#55393 thanks @nickludlam

This commit is contained in:
Gustavo Madeira Santana
2026-03-27 19:03:05 -04:00
parent b73a9cbe7b
commit c6df37ce14
4 changed files with 105 additions and 29 deletions

View File

@@ -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: '<a href="https://matrix.to/#/@bot:matrix.org">R&amp;D Bot</a>: 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: {

View File

@@ -1,10 +1,34 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
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);
}

View File

@@ -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<Record<string, unknown>> => {
if (eventType === "m.room.member") {
return {};
}
return {};
},
),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
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<Record<string, unknown>> => {
throw new Error("member lookup failed");
}),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
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);

View File

@@ -83,27 +83,21 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
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 {