mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: harden live directory lookups
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user