mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 19:32:27 +00:00
matrix: harden display-name mention matching openclaw#55393 thanks @nickludlam
This commit is contained in:
@@ -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&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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user