From dca0f31f4e99cee52f02efe0e7f2c65a37ce673e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 03:47:53 -0400 Subject: [PATCH] Matrix: harden live directory lookups --- docs/channels/matrix.md | 13 ++ extensions/matrix/src/directory-live.test.ts | 108 +++++++++++++-- extensions/matrix/src/directory-live.ts | 133 ++++++++++--------- 3 files changed, 178 insertions(+), 76 deletions(-) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 2344a52685c..438235d58ff 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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. diff --git a/extensions/matrix/src/directory-live.test.ts b/extensions/matrix/src/directory-live.test.ts index 8d856eb6fd9..fd186daafc1 100644 --- a/extensions/matrix/src/directory-live.test.ts +++ b/extensions/matrix/src/directory-live.test.ts @@ -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(); }); }); diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts index 93b320a7a86..32f8bc36bee 100644 --- a/extensions/matrix/src/directory-live.ts +++ b/extensions/matrix/src/directory-live.ts @@ -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>; -async function fetchMatrixJson(params: { - homeserver: string; - path: string; - accessToken: string; - method?: "GET" | "POST"; - body?: unknown; -}): Promise { - 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( + client: MatrixAuthedHttpClient, + params: { + method: "GET" | "POST"; + endpoint: string; + body?: unknown; + }, +): Promise { + 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 { @@ -92,14 +104,16 @@ export async function listMatrixDirectoryPeersLive( if (!context) { return []; } - const { query, auth } = context; - const res = await fetchMatrixJson({ - 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(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 { try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + const res = await requestMatrixJson(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 { try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, + const res = await requestMatrixJson(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({ - homeserver: auth.homeserver, - accessToken: auth.accessToken, - path: "/_matrix/client/v3/joined_rooms", + const joined = await requestMatrixJson(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({