openclaw-remote 0.1.2 → 0.2.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/dist/index.js +812 -0
- package/package.json +4 -4
- package/index.ts +0 -17
- package/src/accounts.ts +0 -80
- package/src/channel.ts +0 -544
- package/src/client.ts +0 -226
- package/src/event-formatter.ts +0 -166
- package/src/realtime-listener.ts +0 -117
- package/src/runtime.ts +0 -9
- package/src/sse-listener.ts +0 -164
- package/src/types.ts +0 -102
package/dist/index.js
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
// src/channel.ts
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
setAccountEnabledInConfigSection,
|
|
5
|
+
waitUntilAbort,
|
|
6
|
+
optionalStringEnum
|
|
7
|
+
} from "openclaw/plugin-sdk/compat";
|
|
8
|
+
import { Type } from "@sinclair/typebox";
|
|
9
|
+
|
|
10
|
+
// src/accounts.ts
|
|
11
|
+
function getChannelConfig(cfg) {
|
|
12
|
+
return cfg?.channels?.remote;
|
|
13
|
+
}
|
|
14
|
+
function parseAllowedUserIds(raw) {
|
|
15
|
+
if (!raw) return [];
|
|
16
|
+
if (Array.isArray(raw)) return raw.filter(Boolean);
|
|
17
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
function listAccountIds(cfg) {
|
|
20
|
+
const channelCfg = getChannelConfig(cfg);
|
|
21
|
+
if (!channelCfg) return [];
|
|
22
|
+
const ids = /* @__PURE__ */ new Set();
|
|
23
|
+
const hasBaseKey = channelCfg.apiKey || process.env.REMOTE_API_KEY;
|
|
24
|
+
if (hasBaseKey) {
|
|
25
|
+
ids.add("default");
|
|
26
|
+
}
|
|
27
|
+
if (channelCfg.accounts) {
|
|
28
|
+
for (const id of Object.keys(channelCfg.accounts)) {
|
|
29
|
+
ids.add(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Array.from(ids);
|
|
33
|
+
}
|
|
34
|
+
function resolveAccount(cfg, accountId) {
|
|
35
|
+
const channelCfg = getChannelConfig(cfg) ?? {};
|
|
36
|
+
const id = accountId || "default";
|
|
37
|
+
const accountOverride = channelCfg.accounts?.[id] ?? {};
|
|
38
|
+
const envBaseUrl = process.env.REMOTE_BASE_URL ?? "";
|
|
39
|
+
const envApiKey = process.env.REMOTE_API_KEY ?? "";
|
|
40
|
+
const envAllowedUserIds = process.env.REMOTE_ALLOWED_USER_IDS ?? "";
|
|
41
|
+
return {
|
|
42
|
+
accountId: id,
|
|
43
|
+
enabled: accountOverride.enabled ?? channelCfg.enabled ?? true,
|
|
44
|
+
baseUrl: (accountOverride.baseUrl ?? channelCfg.baseUrl ?? envBaseUrl).replace(/\/+$/, ""),
|
|
45
|
+
apiKey: accountOverride.apiKey ?? channelCfg.apiKey ?? envApiKey,
|
|
46
|
+
projectId: accountOverride.projectId ?? channelCfg.projectId,
|
|
47
|
+
supabaseUrl: accountOverride.supabaseUrl ?? channelCfg.supabaseUrl ?? process.env.REMOTE_SUPABASE_URL,
|
|
48
|
+
supabaseKey: accountOverride.supabaseKey ?? channelCfg.supabaseKey ?? process.env.REMOTE_SUPABASE_KEY,
|
|
49
|
+
dmPolicy: accountOverride.dmPolicy ?? channelCfg.dmPolicy ?? "open",
|
|
50
|
+
allowedUserIds: parseAllowedUserIds(
|
|
51
|
+
accountOverride.allowedUserIds ?? channelCfg.allowedUserIds ?? envAllowedUserIds
|
|
52
|
+
),
|
|
53
|
+
webhookPath: accountOverride.webhookPath ?? channelCfg.webhookPath
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/client.ts
|
|
58
|
+
function buildHeaders(account) {
|
|
59
|
+
return {
|
|
60
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
Accept: "application/json"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function buildUrl(account, path) {
|
|
66
|
+
return `${account.baseUrl}${path}`;
|
|
67
|
+
}
|
|
68
|
+
async function apiFetch(url, options) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(3e4) });
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const body = await res.text().catch(() => "");
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `HTTP ${res.status}: ${res.statusText}${body ? ` \u2014 ${body.slice(0, 200)}` : ""}`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
return { ok: true, data };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
82
|
+
return { ok: false, error: message };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function postComment(account, taskId, content) {
|
|
86
|
+
return apiFetch(
|
|
87
|
+
buildUrl(account, `/api/v1/tasks/${encodeURIComponent(taskId)}/comments`),
|
|
88
|
+
{
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: buildHeaders(account),
|
|
91
|
+
body: JSON.stringify({ content })
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
async function createTask(account, task) {
|
|
96
|
+
return apiFetch(
|
|
97
|
+
buildUrl(account, "/api/v1/tasks"),
|
|
98
|
+
{
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: buildHeaders(account),
|
|
101
|
+
body: JSON.stringify(task)
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
async function updateTask(account, taskId, updates) {
|
|
106
|
+
return apiFetch(
|
|
107
|
+
buildUrl(account, `/api/v1/tasks/${encodeURIComponent(taskId)}`),
|
|
108
|
+
{
|
|
109
|
+
method: "PATCH",
|
|
110
|
+
headers: buildHeaders(account),
|
|
111
|
+
body: JSON.stringify(updates)
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
async function listTasks(account, filters) {
|
|
116
|
+
const params = new URLSearchParams();
|
|
117
|
+
if (filters?.status) params.set("status", filters.status);
|
|
118
|
+
if (filters?.assigned_to) params.set("assigned_to", filters.assigned_to);
|
|
119
|
+
const qs = params.toString();
|
|
120
|
+
const path = `/api/v1/tasks${qs ? `?${qs}` : ""}`;
|
|
121
|
+
return apiFetch(
|
|
122
|
+
buildUrl(account, path),
|
|
123
|
+
{
|
|
124
|
+
method: "GET",
|
|
125
|
+
headers: buildHeaders(account)
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
async function getHeartbeat(account) {
|
|
130
|
+
return apiFetch(
|
|
131
|
+
buildUrl(account, "/api/v1/heartbeat"),
|
|
132
|
+
{
|
|
133
|
+
method: "GET",
|
|
134
|
+
headers: buildHeaders(account)
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/realtime-listener.ts
|
|
140
|
+
import { createClient } from "@supabase/supabase-js";
|
|
141
|
+
function connectRealtime(account, onEvent, onError, log) {
|
|
142
|
+
const { supabaseUrl, supabaseKey } = account;
|
|
143
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
144
|
+
throw new Error("Supabase URL and key are required for Realtime listener. Set supabaseUrl and supabaseKey in channels.remote config.");
|
|
145
|
+
}
|
|
146
|
+
log?.info?.(`Connecting to Supabase Realtime: ${supabaseUrl}`);
|
|
147
|
+
const supabase = createClient(supabaseUrl, supabaseKey, {
|
|
148
|
+
realtime: {
|
|
149
|
+
params: { eventsPerSecond: 10 }
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const channelName = `remote-${account.accountId}-${Date.now()}`;
|
|
153
|
+
const channel = supabase.channel(channelName).on(
|
|
154
|
+
"postgres_changes",
|
|
155
|
+
{
|
|
156
|
+
event: "INSERT",
|
|
157
|
+
schema: "public",
|
|
158
|
+
table: "task_comments"
|
|
159
|
+
},
|
|
160
|
+
(payload) => {
|
|
161
|
+
try {
|
|
162
|
+
const row = payload.new;
|
|
163
|
+
if (row.agent_author_id && !row.author_id) {
|
|
164
|
+
log?.info?.(`Skipping agent-authored comment on task ${row.task_id} (echo suppression)`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const event = {
|
|
168
|
+
type: "task.commented",
|
|
169
|
+
task_id: row.task_id,
|
|
170
|
+
comment: {
|
|
171
|
+
id: row.id,
|
|
172
|
+
content: row.body || row.content || "",
|
|
173
|
+
author_name: row.author_name || "System",
|
|
174
|
+
author_id: row.author_id || "",
|
|
175
|
+
created_at: row.created_at
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
log?.info?.(`Realtime: new comment on task ${row.task_id}`);
|
|
179
|
+
onEvent(event);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log?.warn?.(`Failed to process comment event: ${err}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
channel.subscribe((status, err) => {
|
|
186
|
+
if (status === "SUBSCRIBED") {
|
|
187
|
+
log?.info?.("Supabase Realtime connected and subscribed.");
|
|
188
|
+
} else if (status === "CHANNEL_ERROR") {
|
|
189
|
+
log?.error?.(`Realtime channel error: ${err?.message || "unknown"}`);
|
|
190
|
+
onError(new Error(`Realtime channel error: ${err?.message || "unknown"}`));
|
|
191
|
+
} else if (status === "TIMED_OUT") {
|
|
192
|
+
log?.warn?.("Realtime subscription timed out, will auto-retry...");
|
|
193
|
+
} else if (status === "CLOSED") {
|
|
194
|
+
log?.info?.("Realtime channel closed.");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
unsubscribe: async () => {
|
|
199
|
+
log?.info?.("Unsubscribing from Supabase Realtime...");
|
|
200
|
+
await supabase.removeChannel(channel);
|
|
201
|
+
log?.info?.("Realtime listener shut down cleanly.");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/event-formatter.ts
|
|
207
|
+
function formatEvent(event) {
|
|
208
|
+
switch (event.type) {
|
|
209
|
+
case "task.created":
|
|
210
|
+
return formatTaskCreated(event);
|
|
211
|
+
case "task.updated":
|
|
212
|
+
return formatTaskUpdated(event);
|
|
213
|
+
case "task.commented":
|
|
214
|
+
return formatTaskCommented(event);
|
|
215
|
+
case "task.assigned":
|
|
216
|
+
return formatTaskAssigned(event);
|
|
217
|
+
default:
|
|
218
|
+
return formatGenericEvent(event);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function formatTaskCreated(event) {
|
|
222
|
+
const task = event.task;
|
|
223
|
+
if (!task) return null;
|
|
224
|
+
const meta = [task.type, task.priority].filter(Boolean).join(", ");
|
|
225
|
+
let body = `\u{1F4CB} New task created: **${task.title}**`;
|
|
226
|
+
if (meta) body += ` [${meta}]`;
|
|
227
|
+
if (task.description) body += `
|
|
228
|
+
${task.description}`;
|
|
229
|
+
body += `
|
|
230
|
+
|
|
231
|
+
[task:${task.id}]`;
|
|
232
|
+
return {
|
|
233
|
+
body,
|
|
234
|
+
taskId: task.id,
|
|
235
|
+
taskTitle: task.title,
|
|
236
|
+
isDirect: false
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function formatTaskUpdated(event) {
|
|
240
|
+
const task = event.task;
|
|
241
|
+
if (!task) return null;
|
|
242
|
+
const changes = event.changes;
|
|
243
|
+
if (changes?.field === "assigned_role_id" || changes?.field === "assigned_to_agent" || changes?.field === "assigned_profile_id") {
|
|
244
|
+
if (changes.to && changes.to !== "null" && changes.to !== "") {
|
|
245
|
+
let body2 = `\u{1F464} You've been assigned: **${task.title}**`;
|
|
246
|
+
if (task.description) body2 += `
|
|
247
|
+
|
|
248
|
+
${task.description}`;
|
|
249
|
+
body2 += `
|
|
250
|
+
|
|
251
|
+
Priority: ${task.priority || "medium"} | Type: ${task.type || "task"}`;
|
|
252
|
+
body2 += `
|
|
253
|
+
|
|
254
|
+
Acknowledge this task, move it to in_progress using remote_update_task, and share your plan.`;
|
|
255
|
+
body2 += `
|
|
256
|
+
|
|
257
|
+
[task:${task.id}]`;
|
|
258
|
+
return {
|
|
259
|
+
body: body2,
|
|
260
|
+
taskId: task.id,
|
|
261
|
+
taskTitle: task.title,
|
|
262
|
+
isDirect: true
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (changes?.field === "status") {
|
|
268
|
+
let body2 = `\u{1F504} Task **${task.title}** moved to **${changes.to}**`;
|
|
269
|
+
body2 += `
|
|
270
|
+
|
|
271
|
+
[task:${task.id}]`;
|
|
272
|
+
return {
|
|
273
|
+
body: body2,
|
|
274
|
+
taskId: task.id,
|
|
275
|
+
taskTitle: task.title,
|
|
276
|
+
isDirect: false
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
let body = `\u{1F504} Task updated: **${task.title}**`;
|
|
280
|
+
if (changes) {
|
|
281
|
+
body += ` \u2014 ${changes.field} changed from "${changes.from}" to "${changes.to}"`;
|
|
282
|
+
}
|
|
283
|
+
body += `
|
|
284
|
+
|
|
285
|
+
[task:${task.id}]`;
|
|
286
|
+
return {
|
|
287
|
+
body,
|
|
288
|
+
taskId: task.id,
|
|
289
|
+
taskTitle: task.title,
|
|
290
|
+
isDirect: false
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function formatTaskCommented(event) {
|
|
294
|
+
const comment = event.comment;
|
|
295
|
+
const taskId = event.task_id ?? event.task?.id;
|
|
296
|
+
if (!comment || !taskId) return null;
|
|
297
|
+
const taskTitle = event.task?.title ?? `Task ${taskId}`;
|
|
298
|
+
let body = `\u{1F4AC} ${comment.author_name} commented on **${taskTitle}**:
|
|
299
|
+
${comment.content}`;
|
|
300
|
+
body += `
|
|
301
|
+
|
|
302
|
+
[task:${taskId}]`;
|
|
303
|
+
return {
|
|
304
|
+
body,
|
|
305
|
+
taskId,
|
|
306
|
+
senderName: comment.author_name,
|
|
307
|
+
senderId: comment.author_id,
|
|
308
|
+
taskTitle,
|
|
309
|
+
isDirect: false
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function formatTaskAssigned(event) {
|
|
313
|
+
const task = event.task;
|
|
314
|
+
const assignee = event.assigned_to;
|
|
315
|
+
if (!task) return null;
|
|
316
|
+
let body = `\u{1F464} Task **${task.title}** assigned`;
|
|
317
|
+
if (assignee) {
|
|
318
|
+
body += ` to ${assignee.name} (${assignee.role})`;
|
|
319
|
+
}
|
|
320
|
+
body += `
|
|
321
|
+
|
|
322
|
+
[task:${task.id}]`;
|
|
323
|
+
return {
|
|
324
|
+
body,
|
|
325
|
+
taskId: task.id,
|
|
326
|
+
senderName: assignee?.name,
|
|
327
|
+
taskTitle: task.title,
|
|
328
|
+
// Assigned events are direct — the agent is likely the assignee
|
|
329
|
+
isDirect: true
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function formatGenericEvent(event) {
|
|
333
|
+
const task = event.task;
|
|
334
|
+
const taskId = event.task_id ?? task?.id;
|
|
335
|
+
if (!taskId) return null;
|
|
336
|
+
const taskTitle = task?.title ?? `Task ${taskId}`;
|
|
337
|
+
let body = `\u{1F514} Remote event (${event.type}): **${taskTitle}**`;
|
|
338
|
+
body += `
|
|
339
|
+
|
|
340
|
+
[task:${taskId}]`;
|
|
341
|
+
return {
|
|
342
|
+
body,
|
|
343
|
+
taskId,
|
|
344
|
+
taskTitle,
|
|
345
|
+
isDirect: false
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/runtime.ts
|
|
350
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
|
351
|
+
var { setRuntime: setRemoteRuntime, getRuntime: getRemoteRuntime } = createPluginRuntimeStore(
|
|
352
|
+
"Remote runtime not initialized - plugin not registered"
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// src/channel.ts
|
|
356
|
+
var CHANNEL_ID = "remote";
|
|
357
|
+
function createRemotePlugin() {
|
|
358
|
+
return {
|
|
359
|
+
id: CHANNEL_ID,
|
|
360
|
+
meta: {
|
|
361
|
+
id: CHANNEL_ID,
|
|
362
|
+
label: "Remote",
|
|
363
|
+
selectionLabel: "Remote (Project Board)",
|
|
364
|
+
detailLabel: "Remote (Project Board)",
|
|
365
|
+
docsPath: "/channels/remote",
|
|
366
|
+
blurb: "Connect agents to Remote project boards as team members",
|
|
367
|
+
order: 95
|
|
368
|
+
},
|
|
369
|
+
capabilities: {
|
|
370
|
+
chatTypes: ["direct", "group"],
|
|
371
|
+
media: false,
|
|
372
|
+
threads: true,
|
|
373
|
+
reactions: false,
|
|
374
|
+
edit: false,
|
|
375
|
+
unsend: false,
|
|
376
|
+
reply: false,
|
|
377
|
+
effects: false,
|
|
378
|
+
blockStreaming: false
|
|
379
|
+
},
|
|
380
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
381
|
+
config: {
|
|
382
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
383
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
|
|
384
|
+
defaultAccountId: (_cfg) => DEFAULT_ACCOUNT_ID,
|
|
385
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
386
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
|
|
387
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
388
|
+
return {
|
|
389
|
+
...cfg,
|
|
390
|
+
channels: {
|
|
391
|
+
...cfg.channels,
|
|
392
|
+
[CHANNEL_ID]: { ...channelConfig, enabled }
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return setAccountEnabledInConfigSection({
|
|
397
|
+
cfg,
|
|
398
|
+
sectionKey: `channels.${CHANNEL_ID}`,
|
|
399
|
+
accountId,
|
|
400
|
+
enabled
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
security: {
|
|
405
|
+
resolveDmPolicy: ({
|
|
406
|
+
cfg,
|
|
407
|
+
accountId,
|
|
408
|
+
account
|
|
409
|
+
}) => {
|
|
410
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
411
|
+
const channelCfg = cfg.channels?.remote;
|
|
412
|
+
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
|
|
413
|
+
const basePath = useAccountPath ? `channels.remote.accounts.${resolvedAccountId}.` : "channels.remote.";
|
|
414
|
+
return {
|
|
415
|
+
policy: account.dmPolicy ?? "open",
|
|
416
|
+
allowFrom: account.allowedUserIds ?? [],
|
|
417
|
+
policyPath: `${basePath}dmPolicy`,
|
|
418
|
+
allowFromPath: basePath,
|
|
419
|
+
approveHint: "openclaw pairing approve remote <code>",
|
|
420
|
+
normalizeEntry: (raw) => raw.toLowerCase().trim()
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
collectWarnings: ({ account }) => {
|
|
424
|
+
const warnings = [];
|
|
425
|
+
if (!account.baseUrl) {
|
|
426
|
+
warnings.push(
|
|
427
|
+
"- Remote: baseUrl is not configured. The plugin cannot connect to the Remote API."
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (!account.apiKey) {
|
|
431
|
+
warnings.push(
|
|
432
|
+
"- Remote: apiKey is not configured. The plugin cannot authenticate with the Remote API."
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (account.dmPolicy === "open") {
|
|
436
|
+
warnings.push(
|
|
437
|
+
'- Remote: dmPolicy="open" allows any board event to trigger agent actions. Consider "allowlist" for production use.'
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return warnings;
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
messaging: {
|
|
444
|
+
normalizeTarget: (target) => {
|
|
445
|
+
const trimmed = target.trim();
|
|
446
|
+
if (!trimmed) return void 0;
|
|
447
|
+
return trimmed.replace(/^remote:/i, "").trim();
|
|
448
|
+
},
|
|
449
|
+
targetResolver: {
|
|
450
|
+
looksLikeId: (id) => {
|
|
451
|
+
const trimmed = id?.trim();
|
|
452
|
+
if (!trimmed) return false;
|
|
453
|
+
return /^remote:/i.test(trimmed) || /^[a-f0-9-]+$/i.test(trimmed);
|
|
454
|
+
},
|
|
455
|
+
hint: "<taskId>"
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
directory: {
|
|
459
|
+
self: async () => null,
|
|
460
|
+
listPeers: async () => [],
|
|
461
|
+
listGroups: async () => []
|
|
462
|
+
},
|
|
463
|
+
outbound: {
|
|
464
|
+
deliveryMode: "gateway",
|
|
465
|
+
textChunkLimit: 4e3,
|
|
466
|
+
sendText: async ({ to, text, accountId, cfg }) => {
|
|
467
|
+
const account = resolveAccount(cfg ?? {}, accountId);
|
|
468
|
+
if (!account.baseUrl || !account.apiKey) {
|
|
469
|
+
throw new Error("Remote baseUrl or apiKey not configured");
|
|
470
|
+
}
|
|
471
|
+
const taskId = extractTaskId(to);
|
|
472
|
+
if (!taskId) {
|
|
473
|
+
throw new Error(`Invalid Remote target: ${to}. Expected format: remote:{taskId}`);
|
|
474
|
+
}
|
|
475
|
+
const result = await postComment(account, taskId, text);
|
|
476
|
+
if (!result.ok) {
|
|
477
|
+
throw new Error(`Failed to post comment to Remote: ${result.error}`);
|
|
478
|
+
}
|
|
479
|
+
const commentId = result.data.comment?.id ?? `rc-${Date.now()}`;
|
|
480
|
+
return { channel: CHANNEL_ID, messageId: commentId, chatId: `remote:${taskId}` };
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
gateway: {
|
|
484
|
+
startAccount: async (ctx) => {
|
|
485
|
+
const { cfg, accountId, log, abortSignal, channelRuntime } = ctx;
|
|
486
|
+
const account = resolveAccount(cfg, accountId);
|
|
487
|
+
if (!account.enabled) {
|
|
488
|
+
log?.info?.(`Remote account ${accountId} is disabled, skipping`);
|
|
489
|
+
return waitUntilAbort(abortSignal);
|
|
490
|
+
}
|
|
491
|
+
if (!account.baseUrl || !account.apiKey) {
|
|
492
|
+
log?.warn?.(
|
|
493
|
+
`Remote account ${accountId} not fully configured (missing baseUrl or apiKey)`
|
|
494
|
+
);
|
|
495
|
+
return waitUntilAbort(abortSignal);
|
|
496
|
+
}
|
|
497
|
+
if (!account.supabaseUrl || !account.supabaseKey) {
|
|
498
|
+
log?.warn?.(
|
|
499
|
+
`Remote account ${accountId} missing supabaseUrl or supabaseKey \u2014 Realtime events disabled. Agent tools still work.`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
log?.info?.(
|
|
503
|
+
`Starting Remote channel (account: ${accountId}, url: ${account.baseUrl})`
|
|
504
|
+
);
|
|
505
|
+
const runtime = channelRuntime ?? getRemoteRuntime().channel;
|
|
506
|
+
let realtimeHandle = null;
|
|
507
|
+
if (account.supabaseUrl && account.supabaseKey) {
|
|
508
|
+
try {
|
|
509
|
+
realtimeHandle = connectRealtime(
|
|
510
|
+
account,
|
|
511
|
+
async (event) => {
|
|
512
|
+
const formatted = formatEvent(event);
|
|
513
|
+
if (!formatted) return;
|
|
514
|
+
try {
|
|
515
|
+
const rt = getRemoteRuntime();
|
|
516
|
+
const currentCfg = await rt.config.loadConfig();
|
|
517
|
+
const sessionKey = `remote:${account.accountId}:${formatted.taskId}`;
|
|
518
|
+
const msgCtx = runtime.reply.finalizeInboundContext({
|
|
519
|
+
Body: formatted.body,
|
|
520
|
+
RawBody: formatted.body,
|
|
521
|
+
CommandBody: formatted.body,
|
|
522
|
+
From: `remote:${formatted.senderId ?? "board"}`,
|
|
523
|
+
To: `remote:${account.accountId}`,
|
|
524
|
+
SessionKey: sessionKey,
|
|
525
|
+
AccountId: account.accountId,
|
|
526
|
+
OriginatingChannel: CHANNEL_ID,
|
|
527
|
+
OriginatingTo: `remote:${formatted.taskId}`,
|
|
528
|
+
ChatType: formatted.isDirect ? "direct" : "group",
|
|
529
|
+
SenderName: formatted.senderName ?? "Remote Board",
|
|
530
|
+
SenderId: formatted.senderId ?? "board",
|
|
531
|
+
Provider: CHANNEL_ID,
|
|
532
|
+
Surface: CHANNEL_ID,
|
|
533
|
+
ConversationLabel: formatted.taskTitle ?? `Task ${formatted.taskId}`,
|
|
534
|
+
Timestamp: Date.now()
|
|
535
|
+
});
|
|
536
|
+
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
537
|
+
ctx: msgCtx,
|
|
538
|
+
cfg: currentCfg,
|
|
539
|
+
dispatcherOptions: {
|
|
540
|
+
deliver: async (payload, _info) => {
|
|
541
|
+
if (payload.isReasoning) return;
|
|
542
|
+
const text = payload?.text;
|
|
543
|
+
if (text) {
|
|
544
|
+
await postComment(account, formatted.taskId, text);
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
onReplyStart: () => {
|
|
548
|
+
log?.info?.(`Agent reply started for task ${formatted.taskId}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
} catch (err) {
|
|
553
|
+
log?.error?.(
|
|
554
|
+
`Error dispatching Remote event: ${err instanceof Error ? err.message : String(err)}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
(err) => {
|
|
559
|
+
log?.warn?.(`Realtime error: ${err.message}`);
|
|
560
|
+
},
|
|
561
|
+
log
|
|
562
|
+
);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
log?.error?.(`Failed to connect Realtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
await waitUntilAbort(abortSignal, async () => {
|
|
568
|
+
if (realtimeHandle) {
|
|
569
|
+
await realtimeHandle.unsubscribe();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
log?.info?.(`Stopped Remote channel (account: ${accountId})`);
|
|
573
|
+
},
|
|
574
|
+
stopAccount: async (ctx) => {
|
|
575
|
+
ctx.log?.info?.(`Remote account ${ctx.accountId} stopped`);
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
agentTools: ((params) => {
|
|
579
|
+
const cfg = params.cfg;
|
|
580
|
+
return [
|
|
581
|
+
// 1. remote_create_task
|
|
582
|
+
{
|
|
583
|
+
name: "remote_create_task",
|
|
584
|
+
label: "Create a new task on the Remote project board",
|
|
585
|
+
description: "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.",
|
|
586
|
+
parameters: Type.Object({
|
|
587
|
+
title: Type.String({ description: "Task title" }),
|
|
588
|
+
description: Type.Optional(Type.String({ description: "Task description" })),
|
|
589
|
+
type: optionalStringEnum(["feature", "task", "bug"], {
|
|
590
|
+
description: "Task type: feature, task, or bug"
|
|
591
|
+
}),
|
|
592
|
+
priority: optionalStringEnum(["low", "medium", "high", "urgent"], {
|
|
593
|
+
description: "Task priority: low, medium, high, or urgent"
|
|
594
|
+
}),
|
|
595
|
+
assigned_role_id: Type.Optional(
|
|
596
|
+
Type.String({ description: "Role ID to assign the task to" })
|
|
597
|
+
)
|
|
598
|
+
}),
|
|
599
|
+
execute: async (_toolCallId, args) => {
|
|
600
|
+
const account = resolveAccount(cfg ?? {});
|
|
601
|
+
const result = await createTask(account, {
|
|
602
|
+
title: args.title,
|
|
603
|
+
description: args.description,
|
|
604
|
+
type: args.type,
|
|
605
|
+
priority: args.priority,
|
|
606
|
+
assigned_role_id: args.assigned_role_id
|
|
607
|
+
});
|
|
608
|
+
if (!result.ok) {
|
|
609
|
+
return {
|
|
610
|
+
content: [{ type: "text", text: `\u274C Failed to create task: ${result.error}` }],
|
|
611
|
+
details: { ok: false, error: result.error }
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const task = result.data.task;
|
|
615
|
+
const text = [
|
|
616
|
+
`\u2705 Task created successfully!`,
|
|
617
|
+
`- **ID**: ${task.id}`,
|
|
618
|
+
`- **Title**: ${task.title}`,
|
|
619
|
+
`- **Type**: ${task.type}`,
|
|
620
|
+
`- **Priority**: ${task.priority}`,
|
|
621
|
+
`- **Status**: ${task.status}`
|
|
622
|
+
].join("\n");
|
|
623
|
+
return {
|
|
624
|
+
content: [{ type: "text", text }],
|
|
625
|
+
details: { ok: true, task }
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
// 2. remote_update_task
|
|
630
|
+
{
|
|
631
|
+
name: "remote_update_task",
|
|
632
|
+
label: "Update a task on the Remote project board",
|
|
633
|
+
description: "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.",
|
|
634
|
+
parameters: Type.Object({
|
|
635
|
+
task_id: Type.String({ description: "ID of the task to update" }),
|
|
636
|
+
status: optionalStringEnum(["todo", "in_progress", "review", "done"], {
|
|
637
|
+
description: "New status: todo, in_progress, review, or done"
|
|
638
|
+
}),
|
|
639
|
+
priority: optionalStringEnum(["low", "medium", "high", "urgent"], {
|
|
640
|
+
description: "New priority: low, medium, high, or urgent"
|
|
641
|
+
}),
|
|
642
|
+
assigned_to: Type.Optional(
|
|
643
|
+
Type.String({ description: "User or role to assign the task to" })
|
|
644
|
+
),
|
|
645
|
+
title: Type.Optional(Type.String({ description: "New task title" })),
|
|
646
|
+
description: Type.Optional(Type.String({ description: "New task description" }))
|
|
647
|
+
}),
|
|
648
|
+
execute: async (_toolCallId, args) => {
|
|
649
|
+
const account = resolveAccount(cfg ?? {});
|
|
650
|
+
const { task_id, ...updates } = args;
|
|
651
|
+
const result = await updateTask(account, task_id, updates);
|
|
652
|
+
if (!result.ok) {
|
|
653
|
+
return {
|
|
654
|
+
content: [{ type: "text", text: `\u274C Failed to update task: ${result.error}` }],
|
|
655
|
+
details: { ok: false, error: result.error }
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const task = result.data.task;
|
|
659
|
+
const text = [
|
|
660
|
+
`\u2705 Task updated successfully!`,
|
|
661
|
+
`- **ID**: ${task.id}`,
|
|
662
|
+
`- **Title**: ${task.title}`,
|
|
663
|
+
`- **Status**: ${task.status}`,
|
|
664
|
+
`- **Priority**: ${task.priority}`
|
|
665
|
+
].join("\n");
|
|
666
|
+
return {
|
|
667
|
+
content: [{ type: "text", text }],
|
|
668
|
+
details: { ok: true, task }
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
// 3. remote_list_tasks
|
|
673
|
+
{
|
|
674
|
+
name: "remote_list_tasks",
|
|
675
|
+
label: "List tasks on the Remote project board",
|
|
676
|
+
description: "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).",
|
|
677
|
+
parameters: Type.Object({
|
|
678
|
+
status: optionalStringEnum(["todo", "in_progress", "review", "done"], {
|
|
679
|
+
description: "Filter by status: todo, in_progress, review, or done"
|
|
680
|
+
}),
|
|
681
|
+
assigned_to: Type.Optional(
|
|
682
|
+
Type.String({ description: "Filter by assignee. Use 'me' for self." })
|
|
683
|
+
)
|
|
684
|
+
}),
|
|
685
|
+
execute: async (_toolCallId, args) => {
|
|
686
|
+
const account = resolveAccount(cfg ?? {});
|
|
687
|
+
const result = await listTasks(account, {
|
|
688
|
+
status: args.status,
|
|
689
|
+
assigned_to: args.assigned_to
|
|
690
|
+
});
|
|
691
|
+
if (!result.ok) {
|
|
692
|
+
return {
|
|
693
|
+
content: [{ type: "text", text: `\u274C Failed to list tasks: ${result.error}` }],
|
|
694
|
+
details: { ok: false, error: result.error }
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const tasks = result.data.tasks;
|
|
698
|
+
if (tasks.length === 0) {
|
|
699
|
+
const filterDesc = [
|
|
700
|
+
args.status && `status=${args.status}`,
|
|
701
|
+
args.assigned_to && `assigned_to=${args.assigned_to}`
|
|
702
|
+
].filter(Boolean).join(", ");
|
|
703
|
+
return {
|
|
704
|
+
content: [
|
|
705
|
+
{
|
|
706
|
+
type: "text",
|
|
707
|
+
text: `\u{1F4CB} No tasks found${filterDesc ? ` (filters: ${filterDesc})` : ""}.`
|
|
708
|
+
}
|
|
709
|
+
],
|
|
710
|
+
details: { ok: true, tasks: [] }
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const lines = [`\u{1F4CB} **${tasks.length} task(s) found:**`, ""];
|
|
714
|
+
for (const task of tasks) {
|
|
715
|
+
const meta = [task.type, task.priority, task.status].filter(Boolean).join(", ");
|
|
716
|
+
lines.push(`- **${task.title}** [${meta}] (id: ${task.id})`);
|
|
717
|
+
if (task.description) {
|
|
718
|
+
const desc = task.description.length > 80 ? task.description.slice(0, 80) + "\u2026" : task.description;
|
|
719
|
+
lines.push(` ${desc}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
724
|
+
details: { ok: true, tasks }
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
// 4. remote_board_health
|
|
729
|
+
{
|
|
730
|
+
name: "remote_board_health",
|
|
731
|
+
label: "Get board health stats from Remote",
|
|
732
|
+
description: "Get board health and statistics from the Remote project board: pending tasks, in-progress tasks, unassigned tasks, recent activity, and your roles.",
|
|
733
|
+
parameters: Type.Object({}),
|
|
734
|
+
execute: async (_toolCallId, _args) => {
|
|
735
|
+
const account = resolveAccount(cfg ?? {});
|
|
736
|
+
const result = await getHeartbeat(account);
|
|
737
|
+
if (!result.ok) {
|
|
738
|
+
return {
|
|
739
|
+
content: [{ type: "text", text: `\u274C Failed to get board health: ${result.error}` }],
|
|
740
|
+
details: { ok: false, error: result.error }
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const h = result.data;
|
|
744
|
+
const text = [
|
|
745
|
+
`\u{1F4CA} **Board Health Summary**`,
|
|
746
|
+
"",
|
|
747
|
+
`- **Pending tasks**: ${h.pending_tasks}`,
|
|
748
|
+
`- **In progress**: ${h.in_progress_tasks}`,
|
|
749
|
+
`- **Unassigned**: ${h.unassigned_tasks}`,
|
|
750
|
+
`- **Activity (24h)**: ${h.recent_activity_24h}`,
|
|
751
|
+
`- **My roles**: ${h.my_roles?.join(", ") || "none"}`,
|
|
752
|
+
`- **Checked at**: ${h.checked_at}`
|
|
753
|
+
].join("\n");
|
|
754
|
+
return {
|
|
755
|
+
content: [{ type: "text", text }],
|
|
756
|
+
details: { ok: true, heartbeat: h }
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
];
|
|
761
|
+
}),
|
|
762
|
+
agentPrompt: {
|
|
763
|
+
messageToolHints: () => [
|
|
764
|
+
"",
|
|
765
|
+
"### Remote Project Board",
|
|
766
|
+
"You are connected to a Remote project board. Messages you receive are task events (new tasks, comments, status changes, assignments).",
|
|
767
|
+
"",
|
|
768
|
+
"**Replying**: When you reply to a task notification, your reply is posted as a comment on that task.",
|
|
769
|
+
"",
|
|
770
|
+
"**Available tools**:",
|
|
771
|
+
"- `remote_create_task` \u2014 Create a new task (specify title, type, priority, etc.)",
|
|
772
|
+
"- `remote_update_task` \u2014 Update a task (change status, priority, assignment, etc.)",
|
|
773
|
+
"- `remote_list_tasks` \u2014 List/filter tasks on the board",
|
|
774
|
+
"- `remote_board_health` \u2014 Get board health stats (pending, in-progress, unassigned counts)",
|
|
775
|
+
"",
|
|
776
|
+
"**Task lifecycle**: todo \u2192 in_progress \u2192 review \u2192 done",
|
|
777
|
+
"**Task types**: feature, task, bug",
|
|
778
|
+
"**Priorities**: low, medium, high, urgent",
|
|
779
|
+
"",
|
|
780
|
+
"**Best practices**:",
|
|
781
|
+
"- When assigned a task, acknowledge it and move to in_progress",
|
|
782
|
+
"- Use comments to communicate progress and blockers",
|
|
783
|
+
"- Move tasks to review when ready for review, done when complete",
|
|
784
|
+
"- Keep task descriptions and comments clear and actionable"
|
|
785
|
+
]
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function extractTaskId(to) {
|
|
790
|
+
if (!to) return void 0;
|
|
791
|
+
const trimmed = to.trim();
|
|
792
|
+
const match = trimmed.match(/^remote:(.+)$/i);
|
|
793
|
+
if (match) return match[1];
|
|
794
|
+
if (trimmed.length > 0) return trimmed;
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// index.ts
|
|
799
|
+
var plugin = {
|
|
800
|
+
id: "remote",
|
|
801
|
+
name: "Remote",
|
|
802
|
+
description: "Remote project board channel plugin for OpenClaw",
|
|
803
|
+
configSchema: { type: "object", properties: {} },
|
|
804
|
+
register(api) {
|
|
805
|
+
setRemoteRuntime(api.runtime);
|
|
806
|
+
api.registerChannel({ plugin: createRemotePlugin() });
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
var index_default = plugin;
|
|
810
|
+
export {
|
|
811
|
+
index_default as default
|
|
812
|
+
};
|