Matrix: harden live directory lookups

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 03:47:53 -04:00
parent 2ad8474b1f
commit dca0f31f4e
3 changed files with 178 additions and 76 deletions

View File

@@ -362,6 +362,19 @@ See [Groups](/channels/groups) for mention-gating and allowlist behavior.
}
```
## Target resolution
Matrix accepts these target forms anywhere OpenClaw asks you for a room or user target:
- Users: `@user:server`, `user:@user:server`, or `matrix:user:@user:server`
- Rooms: `!room:server`, `room:!room:server`, or `matrix:room:!room:server`
- Aliases: `#alias:server`, `channel:#alias:server`, or `matrix:channel:#alias:server`
Live directory lookup uses the logged-in Matrix account:
- User lookups query the Matrix user directory on that homeserver.
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
## Configuration reference
- `enabled`: enable or disable the channel.

View File

@@ -1,11 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
vi.mock("./matrix/sdk/http-client.js", () => ({
MatrixAuthedHttpClient: class {
requestJson(params: unknown) {
return requestJsonMock(params);
}
},
}));
describe("matrix directory live", () => {
const cfg = { channels: { matrix: {} } };
@@ -17,18 +29,8 @@ describe("matrix directory live", () => {
userId: "@bot:example.org",
accessToken: "test-token",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [] }),
text: async () => "",
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ results: [] });
});
it("passes accountId to peer directory auth resolution", async () => {
@@ -61,6 +63,7 @@ describe("matrix directory live", () => {
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
@@ -71,5 +74,84 @@ describe("matrix directory live", () => {
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("preserves query casing when searching the Matrix user directory", async () => {
await listMatrixDirectoryPeersLive({
cfg,
query: "Alice",
limit: 3,
});
expect(requestJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
timeoutMs: 10_000,
body: {
search_term: "Alice",
limit: 3,
},
}),
);
});
it("accepts prefixed fully qualified user ids without hitting Matrix", async () => {
const results = await listMatrixDirectoryPeersLive({
cfg,
query: "matrix:user:@Alice:Example.org",
});
expect(results).toEqual([
{
kind: "user",
id: "@Alice:Example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it("resolves prefixed room aliases through the hardened Matrix HTTP client", async () => {
requestJsonMock.mockResolvedValueOnce({
room_id: "!team:example.org",
});
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "channel:#Team:Example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "#Team:Example.org",
handle: "#Team:Example.org",
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
timeoutMs: 10_000,
}),
);
});
it("accepts prefixed room ids without additional Matrix lookups", async () => {
const results = await listMatrixDirectoryGroupsLive({
cfg,
query: "matrix:room:!team:example.org",
});
expect(results).toEqual([
{
kind: "group",
id: "!team:example.org",
name: "!team:example.org",
},
]);
expect(requestJsonMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,7 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix";
import { resolveMatrixAuth } from "./matrix/client.js";
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
type MatrixUserResult = {
user_id?: string;
@@ -31,45 +33,39 @@ type MatrixDirectoryLiveParams = {
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
const MATRIX_DIRECTORY_TIMEOUT_MS = 10_000;
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
return value?.trim() ?? "";
}
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && limit > 0 ? limit : 20;
return typeof limit === "number" && Number.isFinite(limit) && limit > 0
? Math.max(1, Math.floor(limit))
: 20;
}
async function resolveMatrixDirectoryContext(
params: MatrixDirectoryLiveParams,
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
return new MatrixAuthedHttpClient(auth.homeserver, auth.accessToken);
}
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
auth: MatrixResolvedAuth;
client: MatrixAuthedHttpClient;
query: string;
queryLower: string;
} | null> {
const query = normalizeQuery(params.query);
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return { query, auth };
return {
auth,
client: createMatrixDirectoryClient(auth),
query,
queryLower: query.toLowerCase(),
};
}
function createGroupDirectoryEntry(params: {
@@ -85,6 +81,22 @@ function createGroupDirectoryEntry(params: {
} satisfies ChannelDirectoryEntry;
}
async function requestMatrixJson<T>(
client: MatrixAuthedHttpClient,
params: {
method: "GET" | "POST";
endpoint: string;
body?: unknown;
},
): Promise<T> {
return (await client.requestJson({
method: params.method,
endpoint: params.endpoint,
body: params.body,
timeoutMs: MATRIX_DIRECTORY_TIMEOUT_MS,
})) as T;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
@@ -92,14 +104,16 @@ export async function listMatrixDirectoryPeersLive(
if (!context) {
return [];
}
const { query, auth } = context;
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
const directUserId = normalizeMatrixMessagingTarget(context.query);
if (directUserId && isMatrixQualifiedUserId(directUserId)) {
return [{ kind: "user", id: directUserId }];
}
const res = await requestMatrixJson<MatrixUserDirectoryResponse>(context.client, {
method: "POST",
endpoint: "/_matrix/client/v3/user_directory/search",
body: {
search_term: query,
search_term: context.query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
@@ -122,15 +136,13 @@ export async function listMatrixDirectoryPeersLive(
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
client: MatrixAuthedHttpClient,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
const res = await requestMatrixJson<MatrixAliasLookup>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
@@ -139,15 +151,13 @@ async function resolveMatrixRoomAlias(
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
client: MatrixAuthedHttpClient,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
const res = await requestMatrixJson<MatrixRoomNameState>(client, {
method: "GET",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
@@ -162,35 +172,32 @@ export async function listMatrixDirectoryGroupsLive(
if (!context) {
return [];
}
const { query, auth } = context;
const { client, query, queryLower } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
const directTarget = normalizeMatrixMessagingTarget(query);
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (directTarget?.startsWith("!")) {
return [createGroupDirectoryEntry({ id: directTarget, name: directTarget })];
}
if (directTarget?.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(client, directTarget);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
return [createGroupDirectoryEntry({ id: roomId, name: directTarget, handle: directTarget })];
}
if (query.startsWith("!")) {
return [createGroupDirectoryEntry({ id: query, name: query })];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
const joined = await requestMatrixJson<MatrixJoinedRoomsResponse>(client, {
method: "GET",
endpoint: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const rooms = (joined.joined_rooms ?? []).map((roomId) => roomId.trim()).filter(Boolean);
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) {
continue;
}
if (!name.toLowerCase().includes(query)) {
const name = await fetchMatrixRoomName(client, roomId);
if (!name || !name.toLowerCase().includes(queryLower)) {
continue;
}
results.push({