@zeyiy/openclaw-channel 0.3.4
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/LICENSE +18 -0
- package/README.md +129 -0
- package/README.zh-CN.md +128 -0
- package/dist/channel.d.ts +51 -0
- package/dist/channel.js +74 -0
- package/dist/clients.d.ts +6 -0
- package/dist/clients.js +103 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +168 -0
- package/dist/inbound.d.ts +3 -0
- package/dist/inbound.js +461 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +52 -0
- package/dist/media.d.ts +12 -0
- package/dist/media.js +206 -0
- package/dist/polyfills.d.ts +9 -0
- package/dist/polyfills.js +22 -0
- package/dist/portal.d.ts +11 -0
- package/dist/portal.js +531 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +67 -0
- package/dist/targets.d.ts +6 -0
- package/dist/targets.js +21 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +131 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +31 -0
- package/openclaw.plugin.json +116 -0
- package/package.json +74 -0
package/dist/portal.js
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Bridge — WebSocket client that connects to agent-portal cloud service.
|
|
3
|
+
*
|
|
4
|
+
* Establishes a persistent WS connection to agent-portal using botId as the unique identifier.
|
|
5
|
+
* Handles requests from portal to manage local openclaw agents, files, and models.
|
|
6
|
+
* Lifecycle is tied to the OpenIM account: starts/stops alongside the account.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
|
|
9
|
+
import { resolve, join, dirname } from "node:path";
|
|
10
|
+
const bridges = new Map();
|
|
11
|
+
const RECONNECT_BASE_MS = 2000;
|
|
12
|
+
const RECONNECT_MAX_MS = 60000;
|
|
13
|
+
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
14
|
+
/** Well-known workspace files that agents use */
|
|
15
|
+
const AGENT_FILE_NAMES = [
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"SOUL.md",
|
|
18
|
+
"TOOLS.md",
|
|
19
|
+
"IDENTITY.md",
|
|
20
|
+
"USER.md",
|
|
21
|
+
"HEARTBEAT.md",
|
|
22
|
+
"MEMORY.md",
|
|
23
|
+
"memory.md",
|
|
24
|
+
];
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
function portalLog(api, level, msg) {
|
|
29
|
+
api.logger?.[level]?.(`[portal] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
function getConfig(api) {
|
|
32
|
+
return api.config ?? globalThis.__openimGatewayConfig ?? {};
|
|
33
|
+
}
|
|
34
|
+
function normalizeAgentId(value) {
|
|
35
|
+
const trimmed = (value ?? "").trim();
|
|
36
|
+
if (!trimmed)
|
|
37
|
+
return "main";
|
|
38
|
+
return trimmed.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, 64) || "main";
|
|
39
|
+
}
|
|
40
|
+
function resolveDefaultAgentId(cfg) {
|
|
41
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
42
|
+
if (agents.length === 0)
|
|
43
|
+
return "main";
|
|
44
|
+
const defaults = agents.filter((a) => a?.default);
|
|
45
|
+
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
|
|
46
|
+
return normalizeAgentId(chosen || "main");
|
|
47
|
+
}
|
|
48
|
+
/** Expand leading ~ to $HOME, then resolve to absolute path. */
|
|
49
|
+
function resolveUserPath(p) {
|
|
50
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
51
|
+
if (p.startsWith("~/") || p === "~") {
|
|
52
|
+
return resolve(home, p.slice(2));
|
|
53
|
+
}
|
|
54
|
+
return resolve(p);
|
|
55
|
+
}
|
|
56
|
+
function resolveAgentWorkspaceDir(cfg, agentId) {
|
|
57
|
+
const id = normalizeAgentId(agentId);
|
|
58
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
59
|
+
const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === id);
|
|
60
|
+
if (entry?.workspace?.trim())
|
|
61
|
+
return resolveUserPath(entry.workspace.trim());
|
|
62
|
+
const fallback = cfg.agents?.defaults?.workspace?.trim();
|
|
63
|
+
const defaultId = resolveDefaultAgentId(cfg);
|
|
64
|
+
const home = process.env.HOME ?? process.cwd();
|
|
65
|
+
if (id === defaultId) {
|
|
66
|
+
if (fallback)
|
|
67
|
+
return resolveUserPath(fallback);
|
|
68
|
+
return resolve(home, ".openclaw", "workspace");
|
|
69
|
+
}
|
|
70
|
+
if (fallback)
|
|
71
|
+
return join(resolveUserPath(fallback), id);
|
|
72
|
+
return resolve(home, ".openclaw", `workspace-${id}`);
|
|
73
|
+
}
|
|
74
|
+
function isPathSafe(workspaceRoot, targetPath) {
|
|
75
|
+
const resolved = resolve(workspaceRoot, targetPath);
|
|
76
|
+
return resolved.startsWith(workspaceRoot + "/") || resolved === workspaceRoot;
|
|
77
|
+
}
|
|
78
|
+
async function statFileSafely(filePath) {
|
|
79
|
+
try {
|
|
80
|
+
const s = await stat(filePath);
|
|
81
|
+
return { size: s.size, updatedAtMs: Math.floor(s.mtimeMs) };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Method handlers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/**
|
|
91
|
+
* models.list — return the model catalog from config.
|
|
92
|
+
*
|
|
93
|
+
* Config structure: models.providers.{providerName}.models[]
|
|
94
|
+
* Marks the active model for the given agent. If the agent has no model
|
|
95
|
+
* configured, the first model in the list is marked active.
|
|
96
|
+
*/
|
|
97
|
+
function handleModelsList(api, params) {
|
|
98
|
+
const cfg = getConfig(api);
|
|
99
|
+
const models = [];
|
|
100
|
+
const providers = cfg.models?.providers;
|
|
101
|
+
if (providers && typeof providers === "object") {
|
|
102
|
+
for (const [providerName, provider] of Object.entries(providers)) {
|
|
103
|
+
const providerModels = provider?.models;
|
|
104
|
+
if (!Array.isArray(providerModels))
|
|
105
|
+
continue;
|
|
106
|
+
for (const m of providerModels) {
|
|
107
|
+
if (!m || typeof m !== "object")
|
|
108
|
+
continue;
|
|
109
|
+
const id = String(m.id ?? "").trim();
|
|
110
|
+
if (!id)
|
|
111
|
+
continue;
|
|
112
|
+
models.push({
|
|
113
|
+
id: `${providerName}/${id}`,
|
|
114
|
+
name: String(m.name ?? id),
|
|
115
|
+
provider: providerName,
|
|
116
|
+
...(m.contextWindow ? { contextWindow: Number(m.contextWindow) } : {}),
|
|
117
|
+
...(m.reasoning !== undefined ? { reasoning: Boolean(m.reasoning) } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Resolve active model for the requested agent
|
|
123
|
+
const rawAgentId = String(params.agentId ?? "").trim();
|
|
124
|
+
let activeModelId;
|
|
125
|
+
if (rawAgentId) {
|
|
126
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
127
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
128
|
+
const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
|
|
129
|
+
if (entry?.model) {
|
|
130
|
+
// model can be a string like "deepminer/claude-sonnet-4-6" or an object { primary: "..." }
|
|
131
|
+
activeModelId = typeof entry.model === "string"
|
|
132
|
+
? entry.model.trim()
|
|
133
|
+
: String(entry.model.primary ?? "").trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Fallback: if no agent model configured, first model is active
|
|
137
|
+
if (!activeModelId && models.length > 0) {
|
|
138
|
+
activeModelId = models[0].id;
|
|
139
|
+
}
|
|
140
|
+
if (activeModelId) {
|
|
141
|
+
for (const m of models) {
|
|
142
|
+
m.active = m.id === activeModelId;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { models };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* agents.list — return all configured agents.
|
|
149
|
+
*/
|
|
150
|
+
function handleAgentsList(api) {
|
|
151
|
+
const cfg = getConfig(api);
|
|
152
|
+
const defaultId = resolveDefaultAgentId(cfg);
|
|
153
|
+
const agents = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const entries = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (!entry?.id)
|
|
158
|
+
continue;
|
|
159
|
+
const id = normalizeAgentId(entry.id);
|
|
160
|
+
if (seen.has(id))
|
|
161
|
+
continue;
|
|
162
|
+
seen.add(id);
|
|
163
|
+
const identity = entry.identity
|
|
164
|
+
? {
|
|
165
|
+
...(entry.identity.name ? { name: entry.identity.name } : {}),
|
|
166
|
+
...(entry.identity.theme ? { theme: entry.identity.theme } : {}),
|
|
167
|
+
...(entry.identity.emoji ? { emoji: entry.identity.emoji } : {}),
|
|
168
|
+
...(entry.identity.avatar ? { avatar: entry.identity.avatar } : {}),
|
|
169
|
+
}
|
|
170
|
+
: undefined;
|
|
171
|
+
const model = entry.model
|
|
172
|
+
? typeof entry.model === "string"
|
|
173
|
+
? { primary: entry.model }
|
|
174
|
+
: {
|
|
175
|
+
...(entry.model.primary ? { primary: entry.model.primary } : {}),
|
|
176
|
+
...(Array.isArray(entry.model.fallbacks) ? { fallbacks: entry.model.fallbacks } : {}),
|
|
177
|
+
}
|
|
178
|
+
: undefined;
|
|
179
|
+
agents.push({
|
|
180
|
+
id,
|
|
181
|
+
...(entry.name ? { name: entry.name } : {}),
|
|
182
|
+
...(identity && Object.keys(identity).length > 0 ? { identity } : {}),
|
|
183
|
+
workspace: resolveAgentWorkspaceDir(cfg, id),
|
|
184
|
+
...(model ? { model } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// Ensure default agent is present
|
|
188
|
+
if (!seen.has(defaultId)) {
|
|
189
|
+
agents.unshift({
|
|
190
|
+
id: defaultId,
|
|
191
|
+
workspace: resolveAgentWorkspaceDir(cfg, defaultId),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { defaultId, agents };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* agents.files.list — list workspace files for an agent, including file content.
|
|
198
|
+
*/
|
|
199
|
+
async function handleAgentsFilesList(api, params) {
|
|
200
|
+
const rawAgentId = String(params.agentId ?? "").trim();
|
|
201
|
+
if (!rawAgentId)
|
|
202
|
+
throw { code: 400, message: "agentId is required" };
|
|
203
|
+
const cfg = getConfig(api);
|
|
204
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
205
|
+
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
206
|
+
const files = [];
|
|
207
|
+
for (const name of AGENT_FILE_NAMES) {
|
|
208
|
+
const filePath = join(workspaceDir, name);
|
|
209
|
+
const meta = await statFileSafely(filePath);
|
|
210
|
+
if (meta) {
|
|
211
|
+
let content;
|
|
212
|
+
try {
|
|
213
|
+
content = await readFile(filePath, "utf-8");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// skip unreadable files
|
|
217
|
+
}
|
|
218
|
+
files.push({
|
|
219
|
+
name,
|
|
220
|
+
path: filePath,
|
|
221
|
+
missing: false,
|
|
222
|
+
size: meta.size,
|
|
223
|
+
updatedAtMs: meta.updatedAtMs,
|
|
224
|
+
content,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
files.push({ name, path: filePath, missing: true });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
portalLog(api, "info", `agents.files.list: agentId=${agentId} workspace=${workspaceDir} found=${files.filter(f => !f.missing).length}`);
|
|
232
|
+
return { agentId, workspace: workspaceDir, files };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* agents.files.get — get a single workspace file's content.
|
|
236
|
+
*/
|
|
237
|
+
async function handleAgentsFilesGet(api, params) {
|
|
238
|
+
const rawAgentId = String(params.agentId ?? "").trim();
|
|
239
|
+
const name = String(params.name ?? "").trim();
|
|
240
|
+
if (!rawAgentId)
|
|
241
|
+
throw { code: 400, message: "agentId is required" };
|
|
242
|
+
if (!name)
|
|
243
|
+
throw { code: 400, message: "name is required" };
|
|
244
|
+
const cfg = getConfig(api);
|
|
245
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
246
|
+
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
247
|
+
const filePath = join(workspaceDir, name);
|
|
248
|
+
portalLog(api, "info", `agents.files.get: agentId=${agentId} name=${name} workspace=${workspaceDir} filePath=${filePath}`);
|
|
249
|
+
if (!isPathSafe(workspaceDir, name)) {
|
|
250
|
+
throw { code: 403, message: "path traversal not allowed" };
|
|
251
|
+
}
|
|
252
|
+
const meta = await statFileSafely(filePath);
|
|
253
|
+
if (!meta) {
|
|
254
|
+
portalLog(api, "warn", `agents.files.get: file not found at ${filePath}`);
|
|
255
|
+
return {
|
|
256
|
+
agentId,
|
|
257
|
+
workspace: workspaceDir,
|
|
258
|
+
file: { name, path: filePath, missing: true },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
let content;
|
|
262
|
+
try {
|
|
263
|
+
content = await readFile(filePath, "utf-8");
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// unreadable
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
agentId,
|
|
270
|
+
workspace: workspaceDir,
|
|
271
|
+
file: {
|
|
272
|
+
name,
|
|
273
|
+
path: filePath,
|
|
274
|
+
missing: false,
|
|
275
|
+
size: meta.size,
|
|
276
|
+
updatedAtMs: meta.updatedAtMs,
|
|
277
|
+
content,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* agents.files.set — write a file into an agent's workspace.
|
|
283
|
+
*/
|
|
284
|
+
async function handleAgentsFilesSet(api, params) {
|
|
285
|
+
const rawAgentId = String(params.agentId ?? "").trim();
|
|
286
|
+
const name = String(params.name ?? "").trim();
|
|
287
|
+
const content = String(params.content ?? "");
|
|
288
|
+
if (!rawAgentId)
|
|
289
|
+
throw { code: 400, message: "agentId is required" };
|
|
290
|
+
if (!name)
|
|
291
|
+
throw { code: 400, message: "name is required" };
|
|
292
|
+
const cfg = getConfig(api);
|
|
293
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
294
|
+
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
295
|
+
if (!isPathSafe(workspaceDir, name)) {
|
|
296
|
+
throw { code: 403, message: "path traversal not allowed" };
|
|
297
|
+
}
|
|
298
|
+
const filePath = resolve(workspaceDir, name);
|
|
299
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
300
|
+
await writeFile(filePath, content, "utf-8");
|
|
301
|
+
const meta = await statFileSafely(filePath);
|
|
302
|
+
portalLog(api, "info", `agents.files.set: agentId=${agentId} file=${name} size=${content.length}`);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
agentId,
|
|
306
|
+
workspace: workspaceDir,
|
|
307
|
+
file: {
|
|
308
|
+
name,
|
|
309
|
+
path: filePath,
|
|
310
|
+
missing: false,
|
|
311
|
+
size: meta?.size,
|
|
312
|
+
updatedAtMs: meta?.updatedAtMs,
|
|
313
|
+
content,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* agents.create — create a new agent with workspace and identity file.
|
|
319
|
+
*/
|
|
320
|
+
async function handleAgentsCreate(api, params) {
|
|
321
|
+
const rawName = String(params.name ?? "").trim();
|
|
322
|
+
const rawWorkspace = String(params.workspace ?? "").trim();
|
|
323
|
+
if (!rawName)
|
|
324
|
+
throw { code: 400, message: "name is required" };
|
|
325
|
+
if (!rawWorkspace)
|
|
326
|
+
throw { code: 400, message: "workspace is required" };
|
|
327
|
+
const agentId = normalizeAgentId(rawName);
|
|
328
|
+
if (agentId === "main") {
|
|
329
|
+
throw { code: 400, message: '"main" is reserved' };
|
|
330
|
+
}
|
|
331
|
+
const cfg = getConfig(api);
|
|
332
|
+
const existingAgents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
333
|
+
if (existingAgents.some((a) => a?.id && normalizeAgentId(a.id) === agentId)) {
|
|
334
|
+
throw { code: 400, message: `agent "${agentId}" already exists` };
|
|
335
|
+
}
|
|
336
|
+
const workspaceDir = resolve(rawWorkspace);
|
|
337
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
338
|
+
// Create IDENTITY.md
|
|
339
|
+
const emoji = String(params.emoji ?? "").trim();
|
|
340
|
+
const avatar = String(params.avatar ?? "").trim();
|
|
341
|
+
const lines = [
|
|
342
|
+
"",
|
|
343
|
+
`- Name: ${rawName}`,
|
|
344
|
+
...(emoji ? [`- Emoji: ${emoji}`] : []),
|
|
345
|
+
...(avatar ? [`- Avatar: ${avatar}`] : []),
|
|
346
|
+
"",
|
|
347
|
+
];
|
|
348
|
+
const identityPath = join(workspaceDir, "IDENTITY.md");
|
|
349
|
+
await writeFile(identityPath, lines.join("\n"), "utf-8");
|
|
350
|
+
portalLog(api, "info", `agents.create: agentId=${agentId} name=${rawName} workspace=${workspaceDir}`);
|
|
351
|
+
return { ok: true, agentId, name: rawName, workspace: workspaceDir };
|
|
352
|
+
}
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Request dispatch
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
async function handlePortalRequest(api, request) {
|
|
357
|
+
const { id, method, params } = request;
|
|
358
|
+
portalLog(api, "info", `request received: id=${id} method=${method} params=${JSON.stringify(params)}`);
|
|
359
|
+
try {
|
|
360
|
+
let result;
|
|
361
|
+
switch (method) {
|
|
362
|
+
case "models.list":
|
|
363
|
+
result = handleModelsList(api, params ?? {});
|
|
364
|
+
break;
|
|
365
|
+
case "agents.list":
|
|
366
|
+
result = handleAgentsList(api);
|
|
367
|
+
break;
|
|
368
|
+
case "agents.files.list":
|
|
369
|
+
result = await handleAgentsFilesList(api, params ?? {});
|
|
370
|
+
break;
|
|
371
|
+
case "agents.files.get":
|
|
372
|
+
result = await handleAgentsFilesGet(api, params ?? {});
|
|
373
|
+
break;
|
|
374
|
+
case "agents.files.set":
|
|
375
|
+
result = await handleAgentsFilesSet(api, params ?? {});
|
|
376
|
+
break;
|
|
377
|
+
case "agents.create":
|
|
378
|
+
result = await handleAgentsCreate(api, params ?? {});
|
|
379
|
+
break;
|
|
380
|
+
case "ping":
|
|
381
|
+
result = { pong: true };
|
|
382
|
+
break;
|
|
383
|
+
default:
|
|
384
|
+
throw { code: 404, message: `unknown method: ${method}` };
|
|
385
|
+
}
|
|
386
|
+
return { id, result };
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const code = typeof err?.code === "number" ? err.code : 500;
|
|
390
|
+
const message = err?.message ?? String(err);
|
|
391
|
+
portalLog(api, "error", `request failed: id=${id} method=${method} error=${message}`);
|
|
392
|
+
return { id, error: { code, message } };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// WebSocket connection management (unchanged logic)
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
function sendResponse(ws, response) {
|
|
399
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
400
|
+
ws.send(JSON.stringify(response));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function connectPortal(api, bridge) {
|
|
404
|
+
if (bridge.stopped)
|
|
405
|
+
return;
|
|
406
|
+
const url = `${bridge.portalWsAddr}/${bridge.botId}`;
|
|
407
|
+
portalLog(api, "info", `connecting to agent-portal: url=${url} botId=${bridge.botId} accountId=${bridge.accountId}`);
|
|
408
|
+
let ws;
|
|
409
|
+
try {
|
|
410
|
+
ws = new WebSocket(url);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
portalLog(api, "error", `WebSocket constructor failed: ${err?.message ?? err}`);
|
|
414
|
+
scheduleReconnect(api, bridge, 0);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
bridge.ws = ws;
|
|
418
|
+
let reconnectAttempts = 0;
|
|
419
|
+
ws.addEventListener("open", () => {
|
|
420
|
+
reconnectAttempts = 0;
|
|
421
|
+
portalLog(api, "info", `connected to agent-portal: botId=${bridge.botId}`);
|
|
422
|
+
bridge.heartbeatTimer = setInterval(() => {
|
|
423
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
424
|
+
const pingMsg = { id: `ping-${Date.now()}`, method: "ping", params: {} };
|
|
425
|
+
ws.send(JSON.stringify(pingMsg));
|
|
426
|
+
portalLog(api, "debug", `heartbeat ping sent: botId=${bridge.botId}`);
|
|
427
|
+
}
|
|
428
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
429
|
+
});
|
|
430
|
+
ws.addEventListener("message", async (event) => {
|
|
431
|
+
let raw;
|
|
432
|
+
if (typeof event.data === "string") {
|
|
433
|
+
raw = event.data;
|
|
434
|
+
}
|
|
435
|
+
else if (event.data instanceof ArrayBuffer) {
|
|
436
|
+
raw = new TextDecoder().decode(event.data);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
portalLog(api, "warn", `unexpected message data type: ${typeof event.data}`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
let request;
|
|
443
|
+
try {
|
|
444
|
+
request = JSON.parse(raw);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
portalLog(api, "warn", `invalid JSON from portal: ${raw.slice(0, 200)}`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!request.id || !request.method) {
|
|
451
|
+
portalLog(api, "warn", `malformed request from portal: missing id or method`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const response = await handlePortalRequest(api, request);
|
|
455
|
+
sendResponse(ws, response);
|
|
456
|
+
portalLog(api, "debug", `response sent: id=${request.id} method=${request.method} ok=${!response.error}`);
|
|
457
|
+
});
|
|
458
|
+
ws.addEventListener("close", (event) => {
|
|
459
|
+
portalLog(api, "info", `disconnected from agent-portal: botId=${bridge.botId} code=${event.code} reason=${event.reason || "none"}`);
|
|
460
|
+
clearHeartbeat(bridge);
|
|
461
|
+
bridge.ws = null;
|
|
462
|
+
if (!bridge.stopped) {
|
|
463
|
+
scheduleReconnect(api, bridge, reconnectAttempts++);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
ws.addEventListener("error", (event) => {
|
|
467
|
+
portalLog(api, "error", `WebSocket error: botId=${bridge.botId} error=${event?.message ?? "unknown"}`);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function clearHeartbeat(bridge) {
|
|
471
|
+
if (bridge.heartbeatTimer) {
|
|
472
|
+
clearInterval(bridge.heartbeatTimer);
|
|
473
|
+
bridge.heartbeatTimer = undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function scheduleReconnect(api, bridge, attempt) {
|
|
477
|
+
if (bridge.stopped)
|
|
478
|
+
return;
|
|
479
|
+
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
|
|
480
|
+
portalLog(api, "info", `scheduling reconnect in ${delay}ms (attempt ${attempt + 1}): botId=${bridge.botId}`);
|
|
481
|
+
bridge.reconnectTimer = setTimeout(() => {
|
|
482
|
+
if (!bridge.stopped) {
|
|
483
|
+
connectPortal(api, bridge);
|
|
484
|
+
}
|
|
485
|
+
}, delay);
|
|
486
|
+
}
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Public API
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
export function startPortalBridge(api, config) {
|
|
491
|
+
if (!config.botId || !config.portalWsAddr) {
|
|
492
|
+
portalLog(api, "debug", `portal bridge skipped: botId or portalWsAddr not configured for account ${config.accountId}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (bridges.has(config.accountId)) {
|
|
496
|
+
portalLog(api, "warn", `portal bridge already running for account ${config.accountId}`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const bridge = {
|
|
500
|
+
ws: null,
|
|
501
|
+
accountId: config.accountId,
|
|
502
|
+
botId: config.botId,
|
|
503
|
+
portalWsAddr: config.portalWsAddr,
|
|
504
|
+
stopped: false,
|
|
505
|
+
};
|
|
506
|
+
bridges.set(config.accountId, bridge);
|
|
507
|
+
portalLog(api, "info", `starting portal bridge: accountId=${config.accountId} botId=${config.botId} portalWsAddr=${config.portalWsAddr}`);
|
|
508
|
+
connectPortal(api, bridge);
|
|
509
|
+
}
|
|
510
|
+
export function stopPortalBridge(api, accountId) {
|
|
511
|
+
const bridge = bridges.get(accountId);
|
|
512
|
+
if (!bridge)
|
|
513
|
+
return;
|
|
514
|
+
portalLog(api, "info", `stopping portal bridge: accountId=${accountId} botId=${bridge.botId}`);
|
|
515
|
+
bridge.stopped = true;
|
|
516
|
+
bridges.delete(accountId);
|
|
517
|
+
clearHeartbeat(bridge);
|
|
518
|
+
if (bridge.reconnectTimer) {
|
|
519
|
+
clearTimeout(bridge.reconnectTimer);
|
|
520
|
+
bridge.reconnectTimer = undefined;
|
|
521
|
+
}
|
|
522
|
+
if (bridge.ws) {
|
|
523
|
+
bridge.ws.close(1000, "account stopping");
|
|
524
|
+
bridge.ws = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
export function stopAllPortalBridges(api) {
|
|
528
|
+
for (const accountId of Array.from(bridges.keys())) {
|
|
529
|
+
stopPortalBridge(api, accountId);
|
|
530
|
+
}
|
|
531
|
+
}
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenIM TUI setup wizard
|
|
3
|
+
* openclaw openim setup
|
|
4
|
+
*/
|
|
5
|
+
import { cancel as clackCancel, intro as clackIntro, isCancel, note as clackNote, outro as clackOutro, text as clackText, } from "@clack/prompts";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
const OPENCLAW_HOME = join(homedir(), ".openclaw");
|
|
10
|
+
const CONFIG_PATH = join(OPENCLAW_HOME, "openclaw.json");
|
|
11
|
+
function guardCancel(value) {
|
|
12
|
+
if (isCancel(value)) {
|
|
13
|
+
clackCancel("Operation cancelled.");
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export async function runOpenIMSetup() {
|
|
19
|
+
clackIntro("OpenIM Channel Setup Wizard");
|
|
20
|
+
const token = guardCancel(await clackText({
|
|
21
|
+
message: "Enter OpenIM Access Token",
|
|
22
|
+
initialValue: process.env.OPENIM_TOKEN || "",
|
|
23
|
+
}));
|
|
24
|
+
const wsAddr = guardCancel(await clackText({
|
|
25
|
+
message: "Enter OpenIM WebSocket endpoint",
|
|
26
|
+
initialValue: process.env.OPENIM_WS_ADDR || "ws://127.0.0.1:10001",
|
|
27
|
+
}));
|
|
28
|
+
const apiAddr = guardCancel(await clackText({
|
|
29
|
+
message: "Enter OpenIM REST API endpoint",
|
|
30
|
+
initialValue: process.env.OPENIM_API_ADDR || "http://127.0.0.1:10002",
|
|
31
|
+
}));
|
|
32
|
+
const trimmedToken = String(token).trim();
|
|
33
|
+
const trimmedWsAddr = String(wsAddr).trim();
|
|
34
|
+
const trimmedApiAddr = String(apiAddr).trim();
|
|
35
|
+
if (!trimmedToken || !trimmedWsAddr || !trimmedApiAddr) {
|
|
36
|
+
console.error("Configuration fields `token`, `wsAddr`, and `apiAddr` cannot be empty.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
let existing = {};
|
|
40
|
+
if (existsSync(CONFIG_PATH)) {
|
|
41
|
+
try {
|
|
42
|
+
existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
existing = {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const channels = existing.channels || {};
|
|
49
|
+
const openim = channels.openim || {};
|
|
50
|
+
const accounts = openim.accounts || {};
|
|
51
|
+
accounts.default = {
|
|
52
|
+
enabled: true,
|
|
53
|
+
token: trimmedToken,
|
|
54
|
+
wsAddr: trimmedWsAddr,
|
|
55
|
+
apiAddr: trimmedApiAddr,
|
|
56
|
+
};
|
|
57
|
+
channels.openim = {
|
|
58
|
+
...openim,
|
|
59
|
+
enabled: true,
|
|
60
|
+
accounts,
|
|
61
|
+
};
|
|
62
|
+
const next = { ...existing, channels };
|
|
63
|
+
mkdirSync(OPENCLAW_HOME, { recursive: true });
|
|
64
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), "utf-8");
|
|
65
|
+
clackNote(`Default account configuration written to: ${CONFIG_PATH}\nuserID/platformID are auto-derived from JWT token claims when omitted.`, "Setup complete");
|
|
66
|
+
clackOutro("Run `openclaw gateway restart` to load the updated configuration.");
|
|
67
|
+
}
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function parseTarget(to) {
|
|
2
|
+
const raw = String(to ?? "").trim();
|
|
3
|
+
if (!raw)
|
|
4
|
+
return null;
|
|
5
|
+
const t = raw.replace(/^openim:/i, "");
|
|
6
|
+
if (t.startsWith("user:")) {
|
|
7
|
+
const id = t.slice("user:".length).trim();
|
|
8
|
+
return id ? { kind: "user", id } : null;
|
|
9
|
+
}
|
|
10
|
+
if (t.startsWith("group:")) {
|
|
11
|
+
const id = t.slice("group:".length).trim();
|
|
12
|
+
return id ? { kind: "group", id } : null;
|
|
13
|
+
}
|
|
14
|
+
return { kind: "user", id: t };
|
|
15
|
+
}
|
|
16
|
+
export function getRecvAndGroupID(target) {
|
|
17
|
+
return {
|
|
18
|
+
recvID: target.kind === "user" ? target.id : "",
|
|
19
|
+
groupID: target.kind === "group" ? target.id : "",
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerOpenIMTools(api: any): void;
|