openclaw-remote 0.1.0

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,544 @@
1
+ /**
2
+ * Remote Channel Plugin for OpenClaw.
3
+ *
4
+ * Implements the ChannelPlugin interface following the Synology Chat pattern.
5
+ * Connects to a Remote project board via SSE for inbound events and
6
+ * posts comments for outbound messages.
7
+ */
8
+
9
+ import {
10
+ DEFAULT_ACCOUNT_ID,
11
+ setAccountEnabledInConfigSection,
12
+ waitUntilAbort,
13
+ optionalStringEnum,
14
+ } from "openclaw/plugin-sdk/compat";
15
+ import { Type } from "@sinclair/typebox";
16
+ import { listAccountIds, resolveAccount } from "./accounts.js";
17
+ import { postComment, createTask, updateTask, listTasks, getHeartbeat } from "./client.js";
18
+ import { connectRealtime } from "./realtime-listener.js";
19
+ import { formatEvent } from "./event-formatter.js";
20
+ import { getRemoteRuntime } from "./runtime.js";
21
+ import type { RemoteAccount } from "./types.js";
22
+
23
+ const CHANNEL_ID = "remote";
24
+
25
+ export function createRemotePlugin() {
26
+ return {
27
+ id: CHANNEL_ID,
28
+
29
+ meta: {
30
+ id: CHANNEL_ID,
31
+ label: "Remote",
32
+ selectionLabel: "Remote (Project Board)",
33
+ detailLabel: "Remote (Project Board)",
34
+ docsPath: "/channels/remote",
35
+ blurb: "Connect agents to Remote project boards as team members",
36
+ order: 95,
37
+ },
38
+
39
+ capabilities: {
40
+ chatTypes: ["direct" as const, "group" as const],
41
+ media: false,
42
+ threads: true,
43
+ reactions: false,
44
+ edit: false,
45
+ unsend: false,
46
+ reply: false,
47
+ effects: false,
48
+ blockStreaming: false,
49
+ },
50
+
51
+ reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
52
+
53
+ config: {
54
+ listAccountIds: (cfg: any) => listAccountIds(cfg),
55
+
56
+ resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
57
+
58
+ defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
59
+
60
+ setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
61
+ const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
62
+ if (accountId === DEFAULT_ACCOUNT_ID) {
63
+ return {
64
+ ...cfg,
65
+ channels: {
66
+ ...cfg.channels,
67
+ [CHANNEL_ID]: { ...channelConfig, enabled },
68
+ },
69
+ };
70
+ }
71
+ return setAccountEnabledInConfigSection({
72
+ cfg,
73
+ sectionKey: `channels.${CHANNEL_ID}`,
74
+ accountId,
75
+ enabled,
76
+ });
77
+ },
78
+ },
79
+
80
+ security: {
81
+ resolveDmPolicy: ({
82
+ cfg,
83
+ accountId,
84
+ account,
85
+ }: {
86
+ cfg: any;
87
+ accountId?: string | null;
88
+ account: RemoteAccount;
89
+ }) => {
90
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
91
+ const channelCfg = (cfg as any).channels?.remote;
92
+ const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
93
+ const basePath = useAccountPath
94
+ ? `channels.remote.accounts.${resolvedAccountId}.`
95
+ : "channels.remote.";
96
+ return {
97
+ policy: account.dmPolicy ?? "open",
98
+ allowFrom: account.allowedUserIds ?? [],
99
+ policyPath: `${basePath}dmPolicy`,
100
+ allowFromPath: basePath,
101
+ approveHint: "openclaw pairing approve remote <code>",
102
+ normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
103
+ };
104
+ },
105
+ collectWarnings: ({ account }: { account: RemoteAccount }) => {
106
+ const warnings: string[] = [];
107
+ if (!account.baseUrl) {
108
+ warnings.push(
109
+ "- Remote: baseUrl is not configured. The plugin cannot connect to the Remote API.",
110
+ );
111
+ }
112
+ if (!account.apiKey) {
113
+ warnings.push(
114
+ "- Remote: apiKey is not configured. The plugin cannot authenticate with the Remote API.",
115
+ );
116
+ }
117
+ if (account.dmPolicy === "open") {
118
+ warnings.push(
119
+ '- Remote: dmPolicy="open" allows any board event to trigger agent actions. Consider "allowlist" for production use.',
120
+ );
121
+ }
122
+ return warnings;
123
+ },
124
+ },
125
+
126
+ messaging: {
127
+ normalizeTarget: (target: string) => {
128
+ const trimmed = target.trim();
129
+ if (!trimmed) return undefined;
130
+ return trimmed.replace(/^remote:/i, "").trim();
131
+ },
132
+ targetResolver: {
133
+ looksLikeId: (id: string) => {
134
+ const trimmed = id?.trim();
135
+ if (!trimmed) return false;
136
+ return /^remote:/i.test(trimmed) || /^[a-f0-9-]+$/i.test(trimmed);
137
+ },
138
+ hint: "<taskId>",
139
+ },
140
+ },
141
+
142
+ directory: {
143
+ self: async () => null,
144
+ listPeers: async () => [],
145
+ listGroups: async () => [],
146
+ },
147
+
148
+ outbound: {
149
+ deliveryMode: "gateway" as const,
150
+ textChunkLimit: 4000,
151
+
152
+ sendText: async ({ to, text, accountId, cfg }: any) => {
153
+ const account: RemoteAccount = resolveAccount(cfg ?? {}, accountId);
154
+
155
+ if (!account.baseUrl || !account.apiKey) {
156
+ throw new Error("Remote baseUrl or apiKey not configured");
157
+ }
158
+
159
+ // Parse the target: `remote:{taskId}` → extract taskId
160
+ const taskId = extractTaskId(to);
161
+ if (!taskId) {
162
+ throw new Error(`Invalid Remote target: ${to}. Expected format: remote:{taskId}`);
163
+ }
164
+
165
+ const result = await postComment(account, taskId, text);
166
+ if (!result.ok) {
167
+ throw new Error(`Failed to post comment to Remote: ${result.error}`);
168
+ }
169
+
170
+ const commentId = result.data.comment?.id ?? `rc-${Date.now()}`;
171
+ return { channel: CHANNEL_ID, messageId: commentId, chatId: `remote:${taskId}` };
172
+ },
173
+ },
174
+
175
+ gateway: {
176
+ startAccount: async (ctx: any) => {
177
+ const { cfg, accountId, log, abortSignal, channelRuntime } = ctx;
178
+ const account = resolveAccount(cfg, accountId);
179
+
180
+ if (!account.enabled) {
181
+ log?.info?.(`Remote account ${accountId} is disabled, skipping`);
182
+ return waitUntilAbort(abortSignal);
183
+ }
184
+
185
+ if (!account.baseUrl || !account.apiKey) {
186
+ log?.warn?.(
187
+ `Remote account ${accountId} not fully configured (missing baseUrl or apiKey)`,
188
+ );
189
+ return waitUntilAbort(abortSignal);
190
+ }
191
+
192
+ if (!account.supabaseUrl || !account.supabaseKey) {
193
+ log?.warn?.(
194
+ `Remote account ${accountId} missing supabaseUrl or supabaseKey — Realtime events disabled. Agent tools still work.`,
195
+ );
196
+ }
197
+
198
+ log?.info?.(
199
+ `Starting Remote channel (account: ${accountId}, url: ${account.baseUrl})`,
200
+ );
201
+
202
+ // Use channelRuntime from gateway context (preferred), fall back to stored runtime
203
+ const runtime = channelRuntime ?? getRemoteRuntime().channel;
204
+
205
+ // If Supabase config is present, connect via Realtime for live events
206
+ let realtimeHandle: { unsubscribe: () => Promise<void> } | null = null;
207
+
208
+ if (account.supabaseUrl && account.supabaseKey) {
209
+ try {
210
+ realtimeHandle = connectRealtime(
211
+ account,
212
+ async (event) => {
213
+ const formatted = formatEvent(event);
214
+ if (!formatted) return;
215
+
216
+ try {
217
+ const rt = getRemoteRuntime();
218
+ const currentCfg = await rt.config.loadConfig();
219
+
220
+ const sessionKey = `remote:${account.accountId}:${formatted.taskId}`;
221
+
222
+ const msgCtx = runtime.reply.finalizeInboundContext({
223
+ Body: formatted.body,
224
+ RawBody: formatted.body,
225
+ CommandBody: formatted.body,
226
+ From: `remote:${formatted.senderId ?? "board"}`,
227
+ To: `remote:${account.accountId}`,
228
+ SessionKey: sessionKey,
229
+ AccountId: account.accountId,
230
+ OriginatingChannel: CHANNEL_ID,
231
+ OriginatingTo: `remote:${formatted.taskId}`,
232
+ ChatType: formatted.isDirect ? "direct" : "group",
233
+ SenderName: formatted.senderName ?? "Remote Board",
234
+ SenderId: formatted.senderId ?? "board",
235
+ Provider: CHANNEL_ID,
236
+ Surface: CHANNEL_ID,
237
+ ConversationLabel: formatted.taskTitle ?? `Task ${formatted.taskId}`,
238
+ Timestamp: Date.now(),
239
+ });
240
+
241
+ await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
242
+ ctx: msgCtx,
243
+ cfg: currentCfg,
244
+ dispatcherOptions: {
245
+ deliver: async (payload: { text?: string; mediaUrl?: string; isReasoning?: boolean }, _info) => {
246
+ if (payload.isReasoning) return;
247
+ const text = payload?.text;
248
+ if (text) {
249
+ await postComment(account, formatted.taskId, text);
250
+ }
251
+ },
252
+ onReplyStart: () => {
253
+ log?.info?.(`Agent reply started for task ${formatted.taskId}`);
254
+ },
255
+ },
256
+ });
257
+ } catch (err) {
258
+ log?.error?.(
259
+ `Error dispatching Remote event: ${err instanceof Error ? err.message : String(err)}`,
260
+ );
261
+ }
262
+ },
263
+ (err) => {
264
+ log?.warn?.(`Realtime error: ${err.message}`);
265
+ },
266
+ log,
267
+ );
268
+ } catch (err) {
269
+ log?.error?.(`Failed to connect Realtime: ${err instanceof Error ? err.message : String(err)}`);
270
+ }
271
+ }
272
+
273
+ // Keep alive until abort signal fires
274
+ await waitUntilAbort(abortSignal, async () => {
275
+ if (realtimeHandle) {
276
+ await realtimeHandle.unsubscribe();
277
+ }
278
+ });
279
+
280
+ log?.info?.(`Stopped Remote channel (account: ${accountId})`);
281
+ },
282
+
283
+ stopAccount: async (ctx: any) => {
284
+ ctx.log?.info?.(`Remote account ${ctx.accountId} stopped`);
285
+ },
286
+ },
287
+
288
+ agentTools: ((params: { cfg?: any }) => {
289
+ const cfg = params.cfg;
290
+
291
+ return [
292
+ // 1. remote_create_task
293
+ {
294
+ name: "remote_create_task",
295
+ label: "Create a new task on the Remote project board",
296
+ description:
297
+ "Create a new task on the Remote project board. Specify title, and optionally description, type (feature/task/bug), priority (low/medium/high/urgent), and assigned_role_id.",
298
+ parameters: Type.Object({
299
+ title: Type.String({ description: "Task title" }),
300
+ description: Type.Optional(Type.String({ description: "Task description" })),
301
+ type: optionalStringEnum(["feature", "task", "bug"] as const, {
302
+ description: "Task type: feature, task, or bug",
303
+ }),
304
+ priority: optionalStringEnum(["low", "medium", "high", "urgent"] as const, {
305
+ description: "Task priority: low, medium, high, or urgent",
306
+ }),
307
+ assigned_role_id: Type.Optional(
308
+ Type.String({ description: "Role ID to assign the task to" }),
309
+ ),
310
+ }),
311
+ execute: async (_toolCallId: string, args: any) => {
312
+ const account = resolveAccount(cfg ?? {});
313
+ const result = await createTask(account, {
314
+ title: args.title,
315
+ description: args.description,
316
+ type: args.type,
317
+ priority: args.priority,
318
+ assigned_role_id: args.assigned_role_id,
319
+ });
320
+
321
+ if (!result.ok) {
322
+ return {
323
+ content: [{ type: "text" as const, text: `❌ Failed to create task: ${result.error}` }],
324
+ details: { ok: false, error: result.error },
325
+ };
326
+ }
327
+
328
+ const task = result.data.task;
329
+ const text = [
330
+ `✅ Task created successfully!`,
331
+ `- **ID**: ${task.id}`,
332
+ `- **Title**: ${task.title}`,
333
+ `- **Type**: ${task.type}`,
334
+ `- **Priority**: ${task.priority}`,
335
+ `- **Status**: ${task.status}`,
336
+ ].join("\n");
337
+
338
+ return {
339
+ content: [{ type: "text" as const, text }],
340
+ details: { ok: true, task },
341
+ };
342
+ },
343
+ },
344
+
345
+ // 2. remote_update_task
346
+ {
347
+ name: "remote_update_task",
348
+ label: "Update a task on the Remote project board",
349
+ description:
350
+ "Update an existing task on the Remote project board. Specify the task_id and any fields to change: status (todo/in_progress/review/done), priority, assigned_to, title, or description.",
351
+ parameters: Type.Object({
352
+ task_id: Type.String({ description: "ID of the task to update" }),
353
+ status: optionalStringEnum(["todo", "in_progress", "review", "done"] as const, {
354
+ description: "New status: todo, in_progress, review, or done",
355
+ }),
356
+ priority: optionalStringEnum(["low", "medium", "high", "urgent"] as const, {
357
+ description: "New priority: low, medium, high, or urgent",
358
+ }),
359
+ assigned_to: Type.Optional(
360
+ Type.String({ description: "User or role to assign the task to" }),
361
+ ),
362
+ title: Type.Optional(Type.String({ description: "New task title" })),
363
+ description: Type.Optional(Type.String({ description: "New task description" })),
364
+ }),
365
+ execute: async (_toolCallId: string, args: any) => {
366
+ const account = resolveAccount(cfg ?? {});
367
+ const { task_id, ...updates } = args;
368
+
369
+ const result = await updateTask(account, task_id, updates);
370
+
371
+ if (!result.ok) {
372
+ return {
373
+ content: [{ type: "text" as const, text: `❌ Failed to update task: ${result.error}` }],
374
+ details: { ok: false, error: result.error },
375
+ };
376
+ }
377
+
378
+ const task = result.data.task;
379
+ const text = [
380
+ `✅ Task updated successfully!`,
381
+ `- **ID**: ${task.id}`,
382
+ `- **Title**: ${task.title}`,
383
+ `- **Status**: ${task.status}`,
384
+ `- **Priority**: ${task.priority}`,
385
+ ].join("\n");
386
+
387
+ return {
388
+ content: [{ type: "text" as const, text }],
389
+ details: { ok: true, task },
390
+ };
391
+ },
392
+ },
393
+
394
+ // 3. remote_list_tasks
395
+ {
396
+ name: "remote_list_tasks",
397
+ label: "List tasks on the Remote project board",
398
+ description:
399
+ "List tasks on the Remote project board. Optionally filter by status (todo/in_progress/review/done) and assigned_to (use 'me' for tasks assigned to the agent).",
400
+ parameters: Type.Object({
401
+ status: optionalStringEnum(["todo", "in_progress", "review", "done"] as const, {
402
+ description: "Filter by status: todo, in_progress, review, or done",
403
+ }),
404
+ assigned_to: Type.Optional(
405
+ Type.String({ description: "Filter by assignee. Use 'me' for self." }),
406
+ ),
407
+ }),
408
+ execute: async (_toolCallId: string, args: any) => {
409
+ const account = resolveAccount(cfg ?? {});
410
+ const result = await listTasks(account, {
411
+ status: args.status,
412
+ assigned_to: args.assigned_to,
413
+ });
414
+
415
+ if (!result.ok) {
416
+ return {
417
+ content: [{ type: "text" as const, text: `❌ Failed to list tasks: ${result.error}` }],
418
+ details: { ok: false, error: result.error },
419
+ };
420
+ }
421
+
422
+ const tasks = result.data.tasks;
423
+ if (tasks.length === 0) {
424
+ const filterDesc = [
425
+ args.status && `status=${args.status}`,
426
+ args.assigned_to && `assigned_to=${args.assigned_to}`,
427
+ ]
428
+ .filter(Boolean)
429
+ .join(", ");
430
+
431
+ return {
432
+ content: [
433
+ {
434
+ type: "text" as const,
435
+ text: `📋 No tasks found${filterDesc ? ` (filters: ${filterDesc})` : ""}.`,
436
+ },
437
+ ],
438
+ details: { ok: true, tasks: [] },
439
+ };
440
+ }
441
+
442
+ const lines = [`📋 **${tasks.length} task(s) found:**`, ""];
443
+ for (const task of tasks) {
444
+ const meta = [task.type, task.priority, task.status].filter(Boolean).join(", ");
445
+ lines.push(`- **${task.title}** [${meta}] (id: ${task.id})`);
446
+ if (task.description) {
447
+ const desc = task.description.length > 80
448
+ ? task.description.slice(0, 80) + "…"
449
+ : task.description;
450
+ lines.push(` ${desc}`);
451
+ }
452
+ }
453
+
454
+ return {
455
+ content: [{ type: "text" as const, text: lines.join("\n") }],
456
+ details: { ok: true, tasks },
457
+ };
458
+ },
459
+ },
460
+
461
+ // 4. remote_board_health
462
+ {
463
+ name: "remote_board_health",
464
+ label: "Get board health stats from Remote",
465
+ description:
466
+ "Get board health and statistics from the Remote project board: pending tasks, in-progress tasks, unassigned tasks, recent activity, and your roles.",
467
+ parameters: Type.Object({}),
468
+ execute: async (_toolCallId: string, _args: any) => {
469
+ const account = resolveAccount(cfg ?? {});
470
+ const result = await getHeartbeat(account);
471
+
472
+ if (!result.ok) {
473
+ return {
474
+ content: [{ type: "text" as const, text: `❌ Failed to get board health: ${result.error}` }],
475
+ details: { ok: false, error: result.error },
476
+ };
477
+ }
478
+
479
+ const h = result.data;
480
+ const text = [
481
+ `📊 **Board Health Summary**`,
482
+ "",
483
+ `- **Pending tasks**: ${h.pending_tasks}`,
484
+ `- **In progress**: ${h.in_progress_tasks}`,
485
+ `- **Unassigned**: ${h.unassigned_tasks}`,
486
+ `- **Activity (24h)**: ${h.recent_activity_24h}`,
487
+ `- **My roles**: ${h.my_roles?.join(", ") || "none"}`,
488
+ `- **Checked at**: ${h.checked_at}`,
489
+ ].join("\n");
490
+
491
+ return {
492
+ content: [{ type: "text" as const, text }],
493
+ details: { ok: true, heartbeat: h },
494
+ };
495
+ },
496
+ },
497
+ ];
498
+ }) as any,
499
+
500
+ agentPrompt: {
501
+ messageToolHints: () => [
502
+ "",
503
+ "### Remote Project Board",
504
+ "You are connected to a Remote project board. Messages you receive are task events (new tasks, comments, status changes, assignments).",
505
+ "",
506
+ "**Replying**: When you reply to a task notification, your reply is posted as a comment on that task.",
507
+ "",
508
+ "**Available tools**:",
509
+ "- `remote_create_task` — Create a new task (specify title, type, priority, etc.)",
510
+ "- `remote_update_task` — Update a task (change status, priority, assignment, etc.)",
511
+ "- `remote_list_tasks` — List/filter tasks on the board",
512
+ "- `remote_board_health` — Get board health stats (pending, in-progress, unassigned counts)",
513
+ "",
514
+ "**Task lifecycle**: todo → in_progress → review → done",
515
+ "**Task types**: feature, task, bug",
516
+ "**Priorities**: low, medium, high, urgent",
517
+ "",
518
+ "**Best practices**:",
519
+ "- When assigned a task, acknowledge it and move to in_progress",
520
+ "- Use comments to communicate progress and blockers",
521
+ "- Move tasks to review when ready for review, done when complete",
522
+ "- Keep task descriptions and comments clear and actionable",
523
+ ],
524
+ },
525
+ };
526
+ }
527
+
528
+ /**
529
+ * Extract the task ID from a target string.
530
+ * Supports formats: "remote:{taskId}", "{taskId}"
531
+ */
532
+ function extractTaskId(to: string): string | undefined {
533
+ if (!to) return undefined;
534
+ const trimmed = to.trim();
535
+
536
+ // Strip "remote:" prefix if present
537
+ const match = trimmed.match(/^remote:(.+)$/i);
538
+ if (match) return match[1];
539
+
540
+ // Bare task ID (UUID-like or other ID format)
541
+ if (trimmed.length > 0) return trimmed;
542
+
543
+ return undefined;
544
+ }