@openclaw/matrix 2026.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/actions.ts +185 -0
  6. package/src/channel.directory.test.ts +56 -0
  7. package/src/channel.ts +417 -0
  8. package/src/config-schema.ts +62 -0
  9. package/src/directory-live.ts +175 -0
  10. package/src/group-mentions.ts +61 -0
  11. package/src/matrix/accounts.test.ts +83 -0
  12. package/src/matrix/accounts.ts +63 -0
  13. package/src/matrix/actions/client.ts +53 -0
  14. package/src/matrix/actions/messages.ts +120 -0
  15. package/src/matrix/actions/pins.ts +70 -0
  16. package/src/matrix/actions/reactions.ts +84 -0
  17. package/src/matrix/actions/room.ts +88 -0
  18. package/src/matrix/actions/summary.ts +77 -0
  19. package/src/matrix/actions/types.ts +84 -0
  20. package/src/matrix/actions.ts +15 -0
  21. package/src/matrix/active-client.ts +11 -0
  22. package/src/matrix/client/config.ts +165 -0
  23. package/src/matrix/client/create-client.ts +127 -0
  24. package/src/matrix/client/logging.ts +35 -0
  25. package/src/matrix/client/runtime.ts +4 -0
  26. package/src/matrix/client/shared.ts +169 -0
  27. package/src/matrix/client/storage.ts +131 -0
  28. package/src/matrix/client/types.ts +34 -0
  29. package/src/matrix/client.test.ts +57 -0
  30. package/src/matrix/client.ts +9 -0
  31. package/src/matrix/credentials.ts +103 -0
  32. package/src/matrix/deps.ts +57 -0
  33. package/src/matrix/format.test.ts +34 -0
  34. package/src/matrix/format.ts +22 -0
  35. package/src/matrix/index.ts +11 -0
  36. package/src/matrix/monitor/allowlist.ts +58 -0
  37. package/src/matrix/monitor/auto-join.ts +68 -0
  38. package/src/matrix/monitor/direct.ts +105 -0
  39. package/src/matrix/monitor/events.ts +103 -0
  40. package/src/matrix/monitor/handler.ts +645 -0
  41. package/src/matrix/monitor/index.ts +279 -0
  42. package/src/matrix/monitor/location.ts +83 -0
  43. package/src/matrix/monitor/media.test.ts +103 -0
  44. package/src/matrix/monitor/media.ts +113 -0
  45. package/src/matrix/monitor/mentions.ts +31 -0
  46. package/src/matrix/monitor/replies.ts +96 -0
  47. package/src/matrix/monitor/room-info.ts +58 -0
  48. package/src/matrix/monitor/rooms.ts +43 -0
  49. package/src/matrix/monitor/threads.ts +64 -0
  50. package/src/matrix/monitor/types.ts +39 -0
  51. package/src/matrix/poll-types.test.ts +22 -0
  52. package/src/matrix/poll-types.ts +157 -0
  53. package/src/matrix/probe.ts +70 -0
  54. package/src/matrix/send/client.ts +63 -0
  55. package/src/matrix/send/formatting.ts +92 -0
  56. package/src/matrix/send/media.ts +220 -0
  57. package/src/matrix/send/targets.test.ts +102 -0
  58. package/src/matrix/send/targets.ts +144 -0
  59. package/src/matrix/send/types.ts +109 -0
  60. package/src/matrix/send.test.ts +172 -0
  61. package/src/matrix/send.ts +255 -0
  62. package/src/onboarding.ts +432 -0
  63. package/src/outbound.ts +53 -0
  64. package/src/resolve-targets.ts +89 -0
  65. package/src/runtime.ts +14 -0
  66. package/src/tool-actions.ts +160 -0
  67. package/src/types.ts +95 -0
package/src/channel.ts ADDED
@@ -0,0 +1,417 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ DEFAULT_ACCOUNT_ID,
5
+ deleteAccountFromConfigSection,
6
+ formatPairingApproveHint,
7
+ normalizeAccountId,
8
+ PAIRING_APPROVED_MESSAGE,
9
+ setAccountEnabledInConfigSection,
10
+ type ChannelPlugin,
11
+ } from "openclaw/plugin-sdk";
12
+
13
+ import { matrixMessageActions } from "./actions.js";
14
+ import { MatrixConfigSchema } from "./config-schema.js";
15
+ import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
16
+ import type { CoreConfig } from "./types.js";
17
+ import {
18
+ listMatrixAccountIds,
19
+ resolveDefaultMatrixAccountId,
20
+ resolveMatrixAccount,
21
+ type ResolvedMatrixAccount,
22
+ } from "./matrix/accounts.js";
23
+ import { resolveMatrixAuth } from "./matrix/client.js";
24
+ import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
25
+ import { probeMatrix } from "./matrix/probe.js";
26
+ import { sendMessageMatrix } from "./matrix/send.js";
27
+ import { matrixOnboardingAdapter } from "./onboarding.js";
28
+ import { matrixOutbound } from "./outbound.js";
29
+ import { resolveMatrixTargets } from "./resolve-targets.js";
30
+ import {
31
+ listMatrixDirectoryGroupsLive,
32
+ listMatrixDirectoryPeersLive,
33
+ } from "./directory-live.js";
34
+
35
+ const meta = {
36
+ id: "matrix",
37
+ label: "Matrix",
38
+ selectionLabel: "Matrix (plugin)",
39
+ docsPath: "/channels/matrix",
40
+ docsLabel: "matrix",
41
+ blurb: "open protocol; configure a homeserver + access token.",
42
+ order: 70,
43
+ quickstartAllowFrom: true,
44
+ };
45
+
46
+ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
47
+ let normalized = raw.trim();
48
+ if (!normalized) return undefined;
49
+ const lowered = normalized.toLowerCase();
50
+ if (lowered.startsWith("matrix:")) {
51
+ normalized = normalized.slice("matrix:".length).trim();
52
+ }
53
+ const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
54
+ return stripped || undefined;
55
+ }
56
+
57
+ function buildMatrixConfigUpdate(
58
+ cfg: CoreConfig,
59
+ input: {
60
+ homeserver?: string;
61
+ userId?: string;
62
+ accessToken?: string;
63
+ password?: string;
64
+ deviceName?: string;
65
+ initialSyncLimit?: number;
66
+ },
67
+ ): CoreConfig {
68
+ const existing = cfg.channels?.matrix ?? {};
69
+ return {
70
+ ...cfg,
71
+ channels: {
72
+ ...cfg.channels,
73
+ matrix: {
74
+ ...existing,
75
+ enabled: true,
76
+ ...(input.homeserver ? { homeserver: input.homeserver } : {}),
77
+ ...(input.userId ? { userId: input.userId } : {}),
78
+ ...(input.accessToken ? { accessToken: input.accessToken } : {}),
79
+ ...(input.password ? { password: input.password } : {}),
80
+ ...(input.deviceName ? { deviceName: input.deviceName } : {}),
81
+ ...(typeof input.initialSyncLimit === "number"
82
+ ? { initialSyncLimit: input.initialSyncLimit }
83
+ : {}),
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
90
+ id: "matrix",
91
+ meta,
92
+ onboarding: matrixOnboardingAdapter,
93
+ pairing: {
94
+ idLabel: "matrixUserId",
95
+ normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
96
+ notifyApproval: async ({ id }) => {
97
+ await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
98
+ },
99
+ },
100
+ capabilities: {
101
+ chatTypes: ["direct", "group", "thread"],
102
+ polls: true,
103
+ reactions: true,
104
+ threads: true,
105
+ media: true,
106
+ },
107
+ reload: { configPrefixes: ["channels.matrix"] },
108
+ configSchema: buildChannelConfigSchema(MatrixConfigSchema),
109
+ config: {
110
+ listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
111
+ resolveAccount: (cfg, accountId) =>
112
+ resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
113
+ defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
114
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
115
+ setAccountEnabledInConfigSection({
116
+ cfg: cfg as CoreConfig,
117
+ sectionKey: "matrix",
118
+ accountId,
119
+ enabled,
120
+ allowTopLevel: true,
121
+ }),
122
+ deleteAccount: ({ cfg, accountId }) =>
123
+ deleteAccountFromConfigSection({
124
+ cfg: cfg as CoreConfig,
125
+ sectionKey: "matrix",
126
+ accountId,
127
+ clearBaseFields: [
128
+ "name",
129
+ "homeserver",
130
+ "userId",
131
+ "accessToken",
132
+ "password",
133
+ "deviceName",
134
+ "initialSyncLimit",
135
+ ],
136
+ }),
137
+ isConfigured: (account) => account.configured,
138
+ describeAccount: (account) => ({
139
+ accountId: account.accountId,
140
+ name: account.name,
141
+ enabled: account.enabled,
142
+ configured: account.configured,
143
+ baseUrl: account.homeserver,
144
+ }),
145
+ resolveAllowFrom: ({ cfg }) =>
146
+ ((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
147
+ formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
148
+ },
149
+ security: {
150
+ resolveDmPolicy: ({ account }) => ({
151
+ policy: account.config.dm?.policy ?? "pairing",
152
+ allowFrom: account.config.dm?.allowFrom ?? [],
153
+ policyPath: "channels.matrix.dm.policy",
154
+ allowFromPath: "channels.matrix.dm.allowFrom",
155
+ approveHint: formatPairingApproveHint("matrix"),
156
+ normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
157
+ }),
158
+ collectWarnings: ({ account, cfg }) => {
159
+ const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
160
+ const groupPolicy =
161
+ account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
162
+ if (groupPolicy !== "open") return [];
163
+ return [
164
+ "- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
165
+ ];
166
+ },
167
+ },
168
+ groups: {
169
+ resolveRequireMention: resolveMatrixGroupRequireMention,
170
+ resolveToolPolicy: resolveMatrixGroupToolPolicy,
171
+ },
172
+ threading: {
173
+ resolveReplyToMode: ({ cfg }) =>
174
+ (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
175
+ buildToolContext: ({ context, hasRepliedRef }) => {
176
+ const currentTarget = context.To;
177
+ return {
178
+ currentChannelId: currentTarget?.trim() || undefined,
179
+ currentThreadTs:
180
+ context.MessageThreadId != null
181
+ ? String(context.MessageThreadId)
182
+ : context.ReplyToId,
183
+ hasRepliedRef,
184
+ };
185
+ },
186
+ },
187
+ messaging: {
188
+ normalizeTarget: normalizeMatrixMessagingTarget,
189
+ targetResolver: {
190
+ looksLikeId: (raw) => {
191
+ const trimmed = raw.trim();
192
+ if (!trimmed) return false;
193
+ if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
194
+ return trimmed.includes(":");
195
+ },
196
+ hint: "<room|alias|user>",
197
+ },
198
+ },
199
+ directory: {
200
+ self: async () => null,
201
+ listPeers: async ({ cfg, accountId, query, limit }) => {
202
+ const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
203
+ const q = query?.trim().toLowerCase() || "";
204
+ const ids = new Set<string>();
205
+
206
+ for (const entry of account.config.dm?.allowFrom ?? []) {
207
+ const raw = String(entry).trim();
208
+ if (!raw || raw === "*") continue;
209
+ ids.add(raw.replace(/^matrix:/i, ""));
210
+ }
211
+
212
+ for (const entry of account.config.groupAllowFrom ?? []) {
213
+ const raw = String(entry).trim();
214
+ if (!raw || raw === "*") continue;
215
+ ids.add(raw.replace(/^matrix:/i, ""));
216
+ }
217
+
218
+ const groups = account.config.groups ?? account.config.rooms ?? {};
219
+ for (const room of Object.values(groups)) {
220
+ for (const entry of room.users ?? []) {
221
+ const raw = String(entry).trim();
222
+ if (!raw || raw === "*") continue;
223
+ ids.add(raw.replace(/^matrix:/i, ""));
224
+ }
225
+ }
226
+
227
+ return Array.from(ids)
228
+ .map((raw) => raw.trim())
229
+ .filter(Boolean)
230
+ .map((raw) => {
231
+ const lowered = raw.toLowerCase();
232
+ const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
233
+ if (cleaned.startsWith("@")) return `user:${cleaned}`;
234
+ return cleaned;
235
+ })
236
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
237
+ .slice(0, limit && limit > 0 ? limit : undefined)
238
+ .map((id) => {
239
+ const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
240
+ const incomplete = !raw.startsWith("@") || !raw.includes(":");
241
+ return {
242
+ kind: "user",
243
+ id,
244
+ ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
245
+ };
246
+ });
247
+ },
248
+ listGroups: async ({ cfg, accountId, query, limit }) => {
249
+ const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
250
+ const q = query?.trim().toLowerCase() || "";
251
+ const groups = account.config.groups ?? account.config.rooms ?? {};
252
+ const ids = Object.keys(groups)
253
+ .map((raw) => raw.trim())
254
+ .filter((raw) => Boolean(raw) && raw !== "*")
255
+ .map((raw) => raw.replace(/^matrix:/i, ""))
256
+ .map((raw) => {
257
+ const lowered = raw.toLowerCase();
258
+ if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
259
+ if (raw.startsWith("!")) return `room:${raw}`;
260
+ return raw;
261
+ })
262
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
263
+ .slice(0, limit && limit > 0 ? limit : undefined)
264
+ .map((id) => ({ kind: "group", id }) as const);
265
+ return ids;
266
+ },
267
+ listPeersLive: async ({ cfg, query, limit }) =>
268
+ listMatrixDirectoryPeersLive({ cfg, query, limit }),
269
+ listGroupsLive: async ({ cfg, query, limit }) =>
270
+ listMatrixDirectoryGroupsLive({ cfg, query, limit }),
271
+ },
272
+ resolver: {
273
+ resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
274
+ resolveMatrixTargets({ cfg, inputs, kind, runtime }),
275
+ },
276
+ actions: matrixMessageActions,
277
+ setup: {
278
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
279
+ applyAccountName: ({ cfg, accountId, name }) =>
280
+ applyAccountNameToChannelSection({
281
+ cfg: cfg as CoreConfig,
282
+ channelKey: "matrix",
283
+ accountId,
284
+ name,
285
+ }),
286
+ validateInput: ({ input }) => {
287
+ if (input.useEnv) return null;
288
+ if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
289
+ const accessToken = input.accessToken?.trim();
290
+ const password = input.password?.trim();
291
+ const userId = input.userId?.trim();
292
+ if (!accessToken && !password) {
293
+ return "Matrix requires --access-token or --password";
294
+ }
295
+ if (!accessToken) {
296
+ if (!userId) return "Matrix requires --user-id when using --password";
297
+ if (!password) return "Matrix requires --password when using --user-id";
298
+ }
299
+ return null;
300
+ },
301
+ applyAccountConfig: ({ cfg, input }) => {
302
+ const namedConfig = applyAccountNameToChannelSection({
303
+ cfg: cfg as CoreConfig,
304
+ channelKey: "matrix",
305
+ accountId: DEFAULT_ACCOUNT_ID,
306
+ name: input.name,
307
+ });
308
+ if (input.useEnv) {
309
+ return {
310
+ ...namedConfig,
311
+ channels: {
312
+ ...namedConfig.channels,
313
+ matrix: {
314
+ ...namedConfig.channels?.matrix,
315
+ enabled: true,
316
+ },
317
+ },
318
+ } as CoreConfig;
319
+ }
320
+ return buildMatrixConfigUpdate(namedConfig as CoreConfig, {
321
+ homeserver: input.homeserver?.trim(),
322
+ userId: input.userId?.trim(),
323
+ accessToken: input.accessToken?.trim(),
324
+ password: input.password?.trim(),
325
+ deviceName: input.deviceName?.trim(),
326
+ initialSyncLimit: input.initialSyncLimit,
327
+ });
328
+ },
329
+ },
330
+ outbound: matrixOutbound,
331
+ status: {
332
+ defaultRuntime: {
333
+ accountId: DEFAULT_ACCOUNT_ID,
334
+ running: false,
335
+ lastStartAt: null,
336
+ lastStopAt: null,
337
+ lastError: null,
338
+ },
339
+ collectStatusIssues: (accounts) =>
340
+ accounts.flatMap((account) => {
341
+ const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
342
+ if (!lastError) return [];
343
+ return [
344
+ {
345
+ channel: "matrix",
346
+ accountId: account.accountId,
347
+ kind: "runtime",
348
+ message: `Channel error: ${lastError}`,
349
+ },
350
+ ];
351
+ }),
352
+ buildChannelSummary: ({ snapshot }) => ({
353
+ configured: snapshot.configured ?? false,
354
+ baseUrl: snapshot.baseUrl ?? null,
355
+ running: snapshot.running ?? false,
356
+ lastStartAt: snapshot.lastStartAt ?? null,
357
+ lastStopAt: snapshot.lastStopAt ?? null,
358
+ lastError: snapshot.lastError ?? null,
359
+ probe: snapshot.probe,
360
+ lastProbeAt: snapshot.lastProbeAt ?? null,
361
+ }),
362
+ probeAccount: async ({ account, timeoutMs, cfg }) => {
363
+ try {
364
+ const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
365
+ return await probeMatrix({
366
+ homeserver: auth.homeserver,
367
+ accessToken: auth.accessToken,
368
+ userId: auth.userId,
369
+ timeoutMs,
370
+ });
371
+ } catch (err) {
372
+ return {
373
+ ok: false,
374
+ error: err instanceof Error ? err.message : String(err),
375
+ elapsedMs: 0,
376
+ };
377
+ }
378
+ },
379
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
380
+ accountId: account.accountId,
381
+ name: account.name,
382
+ enabled: account.enabled,
383
+ configured: account.configured,
384
+ baseUrl: account.homeserver,
385
+ running: runtime?.running ?? false,
386
+ lastStartAt: runtime?.lastStartAt ?? null,
387
+ lastStopAt: runtime?.lastStopAt ?? null,
388
+ lastError: runtime?.lastError ?? null,
389
+ probe,
390
+ lastProbeAt: runtime?.lastProbeAt ?? null,
391
+ lastInboundAt: runtime?.lastInboundAt ?? null,
392
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
393
+ }),
394
+ },
395
+ gateway: {
396
+ startAccount: async (ctx) => {
397
+ const account = ctx.account;
398
+ ctx.setStatus({
399
+ accountId: account.accountId,
400
+ baseUrl: account.homeserver,
401
+ });
402
+ ctx.log?.info(
403
+ `[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`,
404
+ );
405
+ // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
406
+ const { monitorMatrixProvider } = await import("./matrix/index.js");
407
+ return monitorMatrixProvider({
408
+ runtime: ctx.runtime,
409
+ abortSignal: ctx.abortSignal,
410
+ mediaMaxMb: account.config.mediaMaxMb,
411
+ initialSyncLimit: account.config.initialSyncLimit,
412
+ replyToMode: account.config.replyToMode,
413
+ accountId: account.accountId,
414
+ });
415
+ },
416
+ },
417
+ };
@@ -0,0 +1,62 @@
1
+ import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+
4
+ const allowFromEntry = z.union([z.string(), z.number()]);
5
+
6
+ const matrixActionSchema = z
7
+ .object({
8
+ reactions: z.boolean().optional(),
9
+ messages: z.boolean().optional(),
10
+ pins: z.boolean().optional(),
11
+ memberInfo: z.boolean().optional(),
12
+ channelInfo: z.boolean().optional(),
13
+ })
14
+ .optional();
15
+
16
+ const matrixDmSchema = z
17
+ .object({
18
+ enabled: z.boolean().optional(),
19
+ policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
20
+ allowFrom: z.array(allowFromEntry).optional(),
21
+ })
22
+ .optional();
23
+
24
+ const matrixRoomSchema = z
25
+ .object({
26
+ enabled: z.boolean().optional(),
27
+ allow: z.boolean().optional(),
28
+ requireMention: z.boolean().optional(),
29
+ tools: ToolPolicySchema,
30
+ autoReply: z.boolean().optional(),
31
+ users: z.array(allowFromEntry).optional(),
32
+ skills: z.array(z.string()).optional(),
33
+ systemPrompt: z.string().optional(),
34
+ })
35
+ .optional();
36
+
37
+ export const MatrixConfigSchema = z.object({
38
+ name: z.string().optional(),
39
+ enabled: z.boolean().optional(),
40
+ markdown: MarkdownConfigSchema,
41
+ homeserver: z.string().optional(),
42
+ userId: z.string().optional(),
43
+ accessToken: z.string().optional(),
44
+ password: z.string().optional(),
45
+ deviceName: z.string().optional(),
46
+ initialSyncLimit: z.number().optional(),
47
+ encryption: z.boolean().optional(),
48
+ allowlistOnly: z.boolean().optional(),
49
+ groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
50
+ replyToMode: z.enum(["off", "first", "all"]).optional(),
51
+ threadReplies: z.enum(["off", "inbound", "always"]).optional(),
52
+ textChunkLimit: z.number().optional(),
53
+ chunkMode: z.enum(["length", "newline"]).optional(),
54
+ mediaMaxMb: z.number().optional(),
55
+ autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
56
+ autoJoinAllowlist: z.array(allowFromEntry).optional(),
57
+ groupAllowFrom: z.array(allowFromEntry).optional(),
58
+ dm: matrixDmSchema,
59
+ groups: z.object({}).catchall(matrixRoomSchema).optional(),
60
+ rooms: z.object({}).catchall(matrixRoomSchema).optional(),
61
+ actions: matrixActionSchema,
62
+ });
@@ -0,0 +1,175 @@
1
+ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
+
3
+ import { resolveMatrixAuth } from "./matrix/client.js";
4
+
5
+ type MatrixUserResult = {
6
+ user_id?: string;
7
+ display_name?: string;
8
+ };
9
+
10
+ type MatrixUserDirectoryResponse = {
11
+ results?: MatrixUserResult[];
12
+ };
13
+
14
+ type MatrixJoinedRoomsResponse = {
15
+ joined_rooms?: string[];
16
+ };
17
+
18
+ type MatrixRoomNameState = {
19
+ name?: string;
20
+ };
21
+
22
+ type MatrixAliasLookup = {
23
+ room_id?: string;
24
+ };
25
+
26
+ async function fetchMatrixJson<T>(params: {
27
+ homeserver: string;
28
+ path: string;
29
+ accessToken: string;
30
+ method?: "GET" | "POST";
31
+ body?: unknown;
32
+ }): Promise<T> {
33
+ const res = await fetch(`${params.homeserver}${params.path}`, {
34
+ method: params.method ?? "GET",
35
+ headers: {
36
+ Authorization: `Bearer ${params.accessToken}`,
37
+ "Content-Type": "application/json",
38
+ },
39
+ body: params.body ? JSON.stringify(params.body) : undefined,
40
+ });
41
+ if (!res.ok) {
42
+ const text = await res.text().catch(() => "");
43
+ throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
44
+ }
45
+ return (await res.json()) as T;
46
+ }
47
+
48
+ function normalizeQuery(value?: string | null): string {
49
+ return value?.trim().toLowerCase() ?? "";
50
+ }
51
+
52
+ export async function listMatrixDirectoryPeersLive(params: {
53
+ cfg: unknown;
54
+ query?: string | null;
55
+ limit?: number | null;
56
+ }): Promise<ChannelDirectoryEntry[]> {
57
+ const query = normalizeQuery(params.query);
58
+ if (!query) return [];
59
+ const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
60
+ const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
61
+ homeserver: auth.homeserver,
62
+ accessToken: auth.accessToken,
63
+ path: "/_matrix/client/v3/user_directory/search",
64
+ method: "POST",
65
+ body: {
66
+ search_term: query,
67
+ limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20,
68
+ },
69
+ });
70
+ const results = res.results ?? [];
71
+ return results
72
+ .map((entry) => {
73
+ const userId = entry.user_id?.trim();
74
+ if (!userId) return null;
75
+ return {
76
+ kind: "user",
77
+ id: userId,
78
+ name: entry.display_name?.trim() || undefined,
79
+ handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
80
+ raw: entry,
81
+ } satisfies ChannelDirectoryEntry;
82
+ })
83
+ .filter(Boolean) as ChannelDirectoryEntry[];
84
+ }
85
+
86
+ async function resolveMatrixRoomAlias(
87
+ homeserver: string,
88
+ accessToken: string,
89
+ alias: string,
90
+ ): Promise<string | null> {
91
+ try {
92
+ const res = await fetchMatrixJson<MatrixAliasLookup>({
93
+ homeserver,
94
+ accessToken,
95
+ path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
96
+ });
97
+ return res.room_id?.trim() || null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function fetchMatrixRoomName(
104
+ homeserver: string,
105
+ accessToken: string,
106
+ roomId: string,
107
+ ): Promise<string | null> {
108
+ try {
109
+ const res = await fetchMatrixJson<MatrixRoomNameState>({
110
+ homeserver,
111
+ accessToken,
112
+ path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
113
+ });
114
+ return res.name?.trim() || null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export async function listMatrixDirectoryGroupsLive(params: {
121
+ cfg: unknown;
122
+ query?: string | null;
123
+ limit?: number | null;
124
+ }): Promise<ChannelDirectoryEntry[]> {
125
+ const query = normalizeQuery(params.query);
126
+ if (!query) return [];
127
+ const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
128
+ const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
129
+
130
+ if (query.startsWith("#")) {
131
+ const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
132
+ if (!roomId) return [];
133
+ return [
134
+ {
135
+ kind: "group",
136
+ id: roomId,
137
+ name: query,
138
+ handle: query,
139
+ } satisfies ChannelDirectoryEntry,
140
+ ];
141
+ }
142
+
143
+ if (query.startsWith("!")) {
144
+ return [
145
+ {
146
+ kind: "group",
147
+ id: query,
148
+ name: query,
149
+ } satisfies ChannelDirectoryEntry,
150
+ ];
151
+ }
152
+
153
+ const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
154
+ homeserver: auth.homeserver,
155
+ accessToken: auth.accessToken,
156
+ path: "/_matrix/client/v3/joined_rooms",
157
+ });
158
+ const rooms = joined.joined_rooms ?? [];
159
+ const results: ChannelDirectoryEntry[] = [];
160
+
161
+ for (const roomId of rooms) {
162
+ const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
163
+ if (!name) continue;
164
+ if (!name.toLowerCase().includes(query)) continue;
165
+ results.push({
166
+ kind: "group",
167
+ id: roomId,
168
+ name,
169
+ handle: `#${name}`,
170
+ });
171
+ if (results.length >= limit) break;
172
+ }
173
+
174
+ return results;
175
+ }