@botcord/openclaw-plugin 0.0.2

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.
package/src/channel.ts ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * BotCord ChannelPlugin — defines meta, capabilities, config,
3
+ * outbound (send via signed envelopes), gateway (start websocket/polling),
4
+ * security, messaging, and status adapters.
5
+ */
6
+ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
7
+ import {
8
+ buildBaseChannelStatusSummary,
9
+ createDefaultChannelRuntimeState,
10
+ DEFAULT_ACCOUNT_ID,
11
+ } from "openclaw/plugin-sdk";
12
+ import {
13
+ resolveChannelConfig,
14
+ resolveAccounts,
15
+ isAccountConfigured,
16
+ displayPrefix,
17
+ } from "./config.js";
18
+ import { BotCordClient } from "./client.js";
19
+ import { getBotCordRuntime } from "./runtime.js";
20
+ import { startPoller, stopPoller } from "./poller.js";
21
+ import { startWsClient, stopWsClient } from "./ws-client.js";
22
+ import type {
23
+ BotCordAccountConfig,
24
+ BotCordChannelConfig,
25
+ MessageAttachment,
26
+ } from "./types.js";
27
+
28
+ // ── Types ────────────────────────────────────────────────────────
29
+
30
+ export interface ResolvedBotCordAccount {
31
+ accountId: string;
32
+ name: string;
33
+ enabled: boolean;
34
+ configured: boolean;
35
+ config: BotCordAccountConfig;
36
+ hubUrl?: string;
37
+ agentId?: string;
38
+ deliveryMode?: "polling" | "websocket";
39
+ }
40
+
41
+ type CoreConfig = any;
42
+
43
+ // ── Account resolution ───────────────────────────────────────────
44
+
45
+ function resolveBotCordAccount(params: {
46
+ cfg: CoreConfig;
47
+ accountId?: string | null;
48
+ }): ResolvedBotCordAccount {
49
+ const channelCfg = resolveChannelConfig(params.cfg);
50
+ const accounts = resolveAccounts(channelCfg);
51
+ const id = params.accountId || Object.keys(accounts)[0] || "default";
52
+ const acct = accounts[id] || ({} as BotCordAccountConfig);
53
+ const rawDeliveryMode = (acct as { deliveryMode?: string }).deliveryMode;
54
+ const deliveryMode = rawDeliveryMode === "polling" || rawDeliveryMode === "webhook"
55
+ ? "polling"
56
+ : "websocket";
57
+
58
+ return {
59
+ accountId: id,
60
+ name: id === "default" ? "BotCord" : `BotCord:${id}`,
61
+ enabled: acct.enabled !== false,
62
+ configured: isAccountConfigured(acct),
63
+ config: acct,
64
+ hubUrl: acct.hubUrl,
65
+ agentId: acct.agentId,
66
+ deliveryMode,
67
+ };
68
+ }
69
+
70
+ function listBotCordAccountIds(cfg: CoreConfig): string[] {
71
+ const channelCfg = resolveChannelConfig(cfg);
72
+ return Object.keys(resolveAccounts(channelCfg));
73
+ }
74
+
75
+ function resolveDefaultAccountId(cfg: CoreConfig): string {
76
+ const ids = listBotCordAccountIds(cfg);
77
+ return ids[0] || "default";
78
+ }
79
+
80
+ // ── Normalize helpers ────────────────────────────────────────────
81
+
82
+ function normalizeBotCordTarget(raw: string): string | undefined {
83
+ const trimmed = raw.trim();
84
+ if (!trimmed) return undefined;
85
+ if (trimmed.startsWith("ag_") || trimmed.startsWith("rm_")) return trimmed;
86
+ if (trimmed.startsWith("botcord:")) return trimmed.slice("botcord:".length);
87
+ return trimmed;
88
+ }
89
+
90
+ function looksLikeBotCordId(raw: string): boolean {
91
+ const t = raw.trim();
92
+ return t.startsWith("ag_") || t.startsWith("rm_") || t.startsWith("botcord:");
93
+ }
94
+
95
+ // ── Config schema ────────────────────────────────────────────────
96
+
97
+ const botCordConfigSchema = {
98
+ type: "object" as const,
99
+ additionalProperties: false as const,
100
+ properties: {
101
+ enabled: { type: "boolean" as const },
102
+ credentialsFile: {
103
+ type: "string" as const,
104
+ description: "Path to a BotCord credentials JSON file",
105
+ },
106
+ hubUrl: { type: "string" as const, description: "BotCord Hub URL" },
107
+ agentId: { type: "string" as const, description: "Agent ID (ag_...)" },
108
+ keyId: { type: "string" as const, description: "Key ID for signing" },
109
+ privateKey: { type: "string" as const, description: "Ed25519 private key (base64)" },
110
+ publicKey: { type: "string" as const, description: "Ed25519 public key (base64)" },
111
+ deliveryMode: {
112
+ type: "string" as const,
113
+ enum: ["websocket", "polling"],
114
+ },
115
+ pollIntervalMs: { type: "number" as const },
116
+ allowFrom: {
117
+ type: "array" as const,
118
+ items: { type: "string" as const },
119
+ },
120
+ notifySession: {
121
+ type: "string" as const,
122
+ description: "Session key to notify when inbound messages arrive (e.g. agent:main:main)",
123
+ },
124
+ accounts: {
125
+ type: "object" as const,
126
+ additionalProperties: {
127
+ type: "object" as const,
128
+ properties: {
129
+ enabled: { type: "boolean" as const },
130
+ credentialsFile: { type: "string" as const },
131
+ hubUrl: { type: "string" as const },
132
+ agentId: { type: "string" as const },
133
+ keyId: { type: "string" as const },
134
+ privateKey: { type: "string" as const },
135
+ publicKey: { type: "string" as const },
136
+ deliveryMode: { type: "string" as const, enum: ["websocket", "polling"] },
137
+ pollIntervalMs: { type: "number" as const },
138
+ },
139
+ },
140
+ },
141
+ },
142
+ };
143
+
144
+ // ── Channel Plugin ───────────────────────────────────────────────
145
+
146
+ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
147
+ id: "botcord",
148
+ meta: {
149
+ id: "botcord",
150
+ label: "BotCord",
151
+ selectionLabel: "BotCord (A2A Protocol)",
152
+ docsPath: "/channels/botcord",
153
+ docsLabel: "botcord",
154
+ blurb: "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes).",
155
+ order: 110,
156
+ quickstartAllowFrom: true,
157
+ },
158
+ capabilities: {
159
+ chatTypes: ["direct", "group"],
160
+ media: true,
161
+ blockStreaming: true,
162
+ },
163
+ reload: { configPrefixes: ["channels.botcord"] },
164
+ configSchema: {
165
+ schema: botCordConfigSchema,
166
+ },
167
+ config: {
168
+ listAccountIds: (cfg) => listBotCordAccountIds(cfg as CoreConfig),
169
+ resolveAccount: (cfg, accountId) =>
170
+ resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId }),
171
+ defaultAccountId: (cfg) => resolveDefaultAccountId(cfg as CoreConfig),
172
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
173
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
174
+ if (isDefault) {
175
+ return {
176
+ ...cfg,
177
+ channels: {
178
+ ...(cfg as any).channels,
179
+ botcord: {
180
+ ...(cfg as any).channels?.botcord,
181
+ enabled,
182
+ },
183
+ },
184
+ };
185
+ }
186
+ const botcordCfg = (cfg as any).channels?.botcord as BotCordChannelConfig | undefined;
187
+ return {
188
+ ...cfg,
189
+ channels: {
190
+ ...(cfg as any).channels,
191
+ botcord: {
192
+ ...botcordCfg,
193
+ accounts: {
194
+ ...botcordCfg?.accounts,
195
+ [accountId]: {
196
+ ...botcordCfg?.accounts?.[accountId],
197
+ enabled,
198
+ },
199
+ },
200
+ },
201
+ },
202
+ };
203
+ },
204
+ deleteAccount: ({ cfg, accountId }) => {
205
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
206
+ if (isDefault) {
207
+ const next = { ...cfg } as ClawdbotConfig;
208
+ const nextChannels = { ...(cfg as any).channels };
209
+ delete (nextChannels as Record<string, unknown>).botcord;
210
+ if (Object.keys(nextChannels).length > 0) {
211
+ next.channels = nextChannels;
212
+ } else {
213
+ delete next.channels;
214
+ }
215
+ return next;
216
+ }
217
+ const botcordCfg = (cfg as any).channels?.botcord as BotCordChannelConfig | undefined;
218
+ const accounts = { ...botcordCfg?.accounts };
219
+ delete accounts[accountId];
220
+ return {
221
+ ...cfg,
222
+ channels: {
223
+ ...(cfg as any).channels,
224
+ botcord: {
225
+ ...botcordCfg,
226
+ accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
227
+ },
228
+ },
229
+ };
230
+ },
231
+ isConfigured: (account) => account.configured,
232
+ describeAccount: (account) => ({
233
+ accountId: account.accountId,
234
+ name: account.name,
235
+ enabled: account.enabled,
236
+ configured: account.configured,
237
+ hubUrl: account.hubUrl,
238
+ agentId: account.agentId,
239
+ deliveryMode: account.deliveryMode,
240
+ }),
241
+ resolveAllowFrom: ({ cfg, accountId }) => {
242
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId });
243
+ return (account.config.allowFrom ?? []).map((entry) => String(entry));
244
+ },
245
+ formatAllowFrom: ({ allowFrom }) =>
246
+ allowFrom
247
+ .map((entry) => String(entry).trim())
248
+ .filter(Boolean)
249
+ .map((entry) => entry.replace(/^botcord:/i, "").toLowerCase()),
250
+ },
251
+ security: {
252
+ collectWarnings: ({ cfg, accountId }) => {
253
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId });
254
+ const warnings: string[] = [];
255
+ if (!account.config.privateKey) {
256
+ warnings.push(
257
+ "- BotCord private key is not configured; messages cannot be signed.",
258
+ );
259
+ }
260
+ return warnings;
261
+ },
262
+ },
263
+ messaging: {
264
+ normalizeTarget: (raw) => normalizeBotCordTarget(raw),
265
+ targetResolver: {
266
+ looksLikeId: looksLikeBotCordId,
267
+ hint: "<ag_id|rm_id>",
268
+ },
269
+ },
270
+ resolver: {
271
+ resolveTargets: async ({ inputs, kind }) => {
272
+ return inputs.map((input) => {
273
+ const normalized = normalizeBotCordTarget(input);
274
+ if (!normalized) {
275
+ return { input, resolved: false, note: "invalid BotCord target" };
276
+ }
277
+ if (kind === "group" && !normalized.startsWith("rm_")) {
278
+ return { input, resolved: false, note: "expected room target (rm_...)" };
279
+ }
280
+ return {
281
+ input,
282
+ resolved: true,
283
+ id: normalized,
284
+ name: normalized,
285
+ };
286
+ });
287
+ },
288
+ },
289
+ directory: {
290
+ self: async ({ cfg, accountId }) => {
291
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId });
292
+ if (!account.configured || !account.agentId) return null;
293
+ try {
294
+ const client = new BotCordClient(account.config);
295
+ const info = await client.resolve(account.agentId);
296
+ return { kind: "user", id: info.agent_id, name: info.display_name || info.agent_id };
297
+ } catch {
298
+ return { kind: "user", id: account.agentId, name: account.agentId };
299
+ }
300
+ },
301
+ listPeers: async ({ cfg, accountId, query, limit }) => {
302
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId });
303
+ if (!account.configured) return [];
304
+ try {
305
+ const client = new BotCordClient(account.config);
306
+ const contacts = await client.listContacts();
307
+ const q = query?.trim().toLowerCase() ?? "";
308
+ return contacts
309
+ .filter(
310
+ (c) =>
311
+ !q ||
312
+ c.contact_agent_id.toLowerCase().includes(q) ||
313
+ c.display_name?.toLowerCase().includes(q),
314
+ )
315
+ .slice(0, limit && limit > 0 ? limit : undefined)
316
+ .map((c) => ({
317
+ kind: "user" as const,
318
+ id: c.contact_agent_id,
319
+ name: c.display_name || c.contact_agent_id,
320
+ }));
321
+ } catch {
322
+ return [];
323
+ }
324
+ },
325
+ listGroups: async ({ cfg, accountId, query, limit }) => {
326
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId });
327
+ if (!account.configured) return [];
328
+ try {
329
+ const client = new BotCordClient(account.config);
330
+ const rooms = await client.listMyRooms();
331
+ const q = query?.trim().toLowerCase() ?? "";
332
+ return rooms
333
+ .filter((r) => !q || r.room_id.toLowerCase().includes(q) || r.name?.toLowerCase().includes(q))
334
+ .slice(0, limit && limit > 0 ? limit : undefined)
335
+ .map((r) => ({ kind: "group" as const, id: r.room_id, name: r.name || r.room_id }));
336
+ } catch {
337
+ return [];
338
+ }
339
+ },
340
+ },
341
+ outbound: {
342
+ deliveryMode: "direct",
343
+ chunker: (text, limit) => getBotCordRuntime().channel.text.chunkMarkdownText(text, limit),
344
+ chunkerMode: "markdown",
345
+ textChunkLimit: 4000,
346
+ sendText: async ({ cfg, to, text, accountId }) => {
347
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
348
+ const client = new BotCordClient(account.config);
349
+ const result = await client.sendMessage(to, text);
350
+ return {
351
+ channel: "botcord",
352
+ ok: true,
353
+ messageId: result.hub_msg_id,
354
+ };
355
+ },
356
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
357
+ const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
358
+ const client = new BotCordClient(account.config);
359
+ const attachments: MessageAttachment[] = [];
360
+ if (mediaUrl) {
361
+ const filename = mediaUrl.split("/").pop() || "attachment";
362
+ attachments.push({ filename, url: mediaUrl });
363
+ }
364
+ const result = await client.sendMessage(to, text, {
365
+ attachments: attachments.length > 0 ? attachments : undefined,
366
+ });
367
+ return {
368
+ channel: "botcord",
369
+ ok: true,
370
+ messageId: result.hub_msg_id,
371
+ };
372
+ },
373
+ },
374
+ status: {
375
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
376
+ buildChannelSummary: ({ snapshot }) => ({
377
+ ...buildBaseChannelStatusSummary(snapshot),
378
+ }),
379
+ buildAccountSnapshot: ({ account, runtime }) => ({
380
+ accountId: account.accountId,
381
+ enabled: account.enabled,
382
+ configured: account.configured,
383
+ name: account.name,
384
+ hubUrl: account.hubUrl,
385
+ agentId: account.agentId,
386
+ deliveryMode: account.deliveryMode,
387
+ running: runtime?.running ?? false,
388
+ lastStartAt: runtime?.lastStartAt ?? null,
389
+ lastStopAt: runtime?.lastStopAt ?? null,
390
+ lastError: runtime?.lastError ?? null,
391
+ }),
392
+ },
393
+ gateway: {
394
+ startAccount: async (ctx) => {
395
+ const account = ctx.account;
396
+ if (!account.configured) {
397
+ throw new Error(
398
+ `BotCord is not configured for account "${account.accountId}" (need hubUrl, agentId, keyId, privateKey).`,
399
+ );
400
+ }
401
+
402
+ const dp = displayPrefix(account.accountId, ctx.cfg);
403
+ ctx.log?.info(`[${dp}] starting BotCord gateway (${account.deliveryMode} mode)`);
404
+
405
+ const client = new BotCordClient(account.config);
406
+ const mode = account.deliveryMode || "websocket";
407
+
408
+ if (mode === "websocket") {
409
+ ctx.log?.info(`[${dp}] starting WebSocket connection to Hub`);
410
+ startWsClient({
411
+ client,
412
+ accountId: account.accountId,
413
+ cfg: ctx.cfg,
414
+ abortSignal: ctx.abortSignal,
415
+ log: ctx.log,
416
+ });
417
+ } else {
418
+ startPoller({
419
+ client,
420
+ accountId: account.accountId,
421
+ cfg: ctx.cfg,
422
+ intervalMs: account.config.pollIntervalMs || 5000,
423
+ abortSignal: ctx.abortSignal,
424
+ log: ctx.log,
425
+ });
426
+ }
427
+
428
+ ctx.setStatus({ accountId: ctx.accountId, running: true, lastStartAt: Date.now() });
429
+
430
+ // Keep the promise alive until the gateway signals shutdown via abortSignal.
431
+ // If we return immediately, the gateway considers the channel "stopped" and
432
+ // enters an auto-restart loop.
433
+ await new Promise<void>((resolve) => {
434
+ if (ctx.abortSignal?.aborted) {
435
+ resolve();
436
+ return;
437
+ }
438
+ ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
439
+ });
440
+
441
+ stopWsClient(account.accountId);
442
+ stopPoller(account.accountId);
443
+ ctx.setStatus({ accountId: ctx.accountId, running: false, lastStopAt: Date.now() });
444
+ },
445
+ },
446
+ };