create-openclaw-bot 5.7.10 → 5.8.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/README.md +159 -315
- package/README.vi.md +164 -315
- package/dist/cli.js +111 -2809
- package/dist/legacy-cli.js +2812 -0
- package/dist/server/local-server.js +2372 -0
- package/dist/setup/data/header.js +80 -80
- package/dist/setup/shared/bot-config-gen.js +469 -462
- package/dist/setup/shared/common-gen.js +313 -315
- package/dist/setup/shared/docker-gen.js +574 -500
- package/dist/setup/shared/install-gen.js +566 -566
- package/dist/setup/shared/workspace-gen.js +2 -1
- package/dist/setup.js +396 -204
- package/dist/web/app.js +1106 -0
- package/dist/web/bvvbank.jpg +0 -0
- package/dist/web/index.html +14 -0
- package/dist/web/momo.jpg +0 -0
- package/dist/web/openclaw-logo.png +0 -0
- package/dist/web/openclaw-logo.svg +1 -0
- package/dist/web/styles.css +607 -0
- package/package.json +3 -2
|
@@ -0,0 +1,2372 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs, { createReadStream, existsSync, promises as fsp } from 'fs';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { basename, dirname, extname, join, normalize, resolve } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import net from 'net';
|
|
9
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
+
const _require = createRequire(import.meta.url);
|
|
11
|
+
function loadSharedModule(modulePath, globalName) {
|
|
12
|
+
const loaded = _require(modulePath);
|
|
13
|
+
if (loaded && Object.keys(loaded).length > 0) return loaded;
|
|
14
|
+
return globalThis[globalName] || loaded || {};
|
|
15
|
+
}
|
|
16
|
+
const { buildWorkspaceFileMap } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
|
|
17
|
+
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
18
|
+
const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
|
|
19
|
+
const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
|
|
20
|
+
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const WEB_DIR = resolve(__dirname, '../web');
|
|
24
|
+
const DEFAULT_PROJECT_NAME = 'openclaw-bot';
|
|
25
|
+
const STATE_FILE = '.openclaw-setup-state.json';
|
|
26
|
+
const DEFAULT_MODEL = 'smart-route';
|
|
27
|
+
const logClients = new Set();
|
|
28
|
+
let zaloLoginInFlight = false;
|
|
29
|
+
const state = {
|
|
30
|
+
installing: false,
|
|
31
|
+
installed: false,
|
|
32
|
+
lastError: null,
|
|
33
|
+
projectDir: null,
|
|
34
|
+
gatewayUrl: 'http://127.0.0.1:18789',
|
|
35
|
+
gatewayPort: 18789,
|
|
36
|
+
routerUrl: 'http://127.0.0.1:20128',
|
|
37
|
+
routerPort: 20128,
|
|
38
|
+
syncSource: 'config',
|
|
39
|
+
botPid: null,
|
|
40
|
+
mode: null,
|
|
41
|
+
os: null,
|
|
42
|
+
startedAt: null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function sendLog(line) {
|
|
46
|
+
const payload = `data: ${JSON.stringify({ line, ts: new Date().toISOString() })}\n\n`;
|
|
47
|
+
for (const res of logClients) res.write(payload);
|
|
48
|
+
process.stdout.write(`${line}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractCompletePngBase64(stdout) {
|
|
52
|
+
const b64 = String(stdout || '').trim();
|
|
53
|
+
if (b64.length < 100) return '';
|
|
54
|
+
let buf;
|
|
55
|
+
try {
|
|
56
|
+
buf = Buffer.from(b64, 'base64');
|
|
57
|
+
} catch {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
if (!buf || buf.length < 32) return '';
|
|
61
|
+
const pngSig = '89504e470d0a1a0a';
|
|
62
|
+
const hasSig = buf.subarray(0, 8).toString('hex') === pngSig;
|
|
63
|
+
const hasIend = buf.includes(Buffer.from('49454e44ae426082', 'hex'));
|
|
64
|
+
if (!hasSig || !hasIend) return '';
|
|
65
|
+
return b64;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectOs() {
|
|
69
|
+
const platform = process.platform;
|
|
70
|
+
if (platform === 'win32') return 'win';
|
|
71
|
+
if (platform === 'darwin') return 'macos';
|
|
72
|
+
if (platform === 'linux') return os.release().toLowerCase().includes('microsoft') ? 'linux-desktop' : 'linux-desktop';
|
|
73
|
+
return 'linux-desktop';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function recommendedMode(osChoice) {
|
|
77
|
+
if (osChoice === 'win' || osChoice === 'macos') return 'docker';
|
|
78
|
+
return 'native';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function commandExists(cmd, args = ['--version']) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const shell = process.platform === 'win32';
|
|
84
|
+
execFile(cmd, args, { windowsHide: true, timeout: 5000, shell }, (err, stdout, stderr) => {
|
|
85
|
+
resolve({ ok: !err, output: String(stdout || stderr || '').trim() });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function run(cmd, args, opts = {}) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
sendLog(`$ ${cmd} ${args.join(' ')}`);
|
|
93
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, shell: process.platform === 'win32', env: { ...process.env, ...(opts.env || {}) } });
|
|
94
|
+
let stdout = '';
|
|
95
|
+
let resolved = false;
|
|
96
|
+
child.stdout.on('data', (d) => {
|
|
97
|
+
const chunk = String(d);
|
|
98
|
+
stdout += chunk;
|
|
99
|
+
sendLog(chunk.trimEnd());
|
|
100
|
+
if (opts.resolveOnPattern && opts.resolveOnPattern.test(stdout) && !resolved) {
|
|
101
|
+
resolved = true;
|
|
102
|
+
resolve();
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
105
|
+
}, 1000);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
|
|
109
|
+
child.on('error', (err) => {
|
|
110
|
+
if (!resolved) reject(err);
|
|
111
|
+
});
|
|
112
|
+
child.on('close', (code) => {
|
|
113
|
+
if (!resolved) {
|
|
114
|
+
if (code === 0) resolve();
|
|
115
|
+
else reject(new Error(`${cmd} exited ${code}`));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startDetached(cmd, args, opts = {}) {
|
|
122
|
+
sendLog(`$ ${cmd} ${args.join(' ')} &`);
|
|
123
|
+
const child = spawn(cmd, args, {
|
|
124
|
+
cwd: opts.cwd,
|
|
125
|
+
shell: process.platform === 'win32',
|
|
126
|
+
detached: true,
|
|
127
|
+
stdio: 'ignore',
|
|
128
|
+
windowsHide: opts.windowsHide ?? true,
|
|
129
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
130
|
+
});
|
|
131
|
+
child.unref();
|
|
132
|
+
return child.pid;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function getCurrentRuntimeVersions() {
|
|
136
|
+
const [openclaw, nineRouter, node] = await Promise.all([
|
|
137
|
+
commandExists('openclaw', ['--version']),
|
|
138
|
+
commandExists('9router', ['--version']),
|
|
139
|
+
commandExists('node', ['--version']),
|
|
140
|
+
]);
|
|
141
|
+
return {
|
|
142
|
+
openclaw: openclaw.ok ? (openclaw.output.split(/\r?\n/)[0] || '').trim() : '',
|
|
143
|
+
nineRouter: nineRouter.ok ? (nineRouter.output.split(/\r?\n/)[0] || '').trim() : '',
|
|
144
|
+
node: node.ok ? (node.output.split(/\r?\n/)[0] || '').trim() : process.version,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function resolveProjectRuntimeVersions(projectDir, mode = state.mode || 'docker') {
|
|
149
|
+
const fallback = {
|
|
150
|
+
openclaw: '',
|
|
151
|
+
nineRouter: '',
|
|
152
|
+
node: process.version || '',
|
|
153
|
+
};
|
|
154
|
+
if (!projectDir) return fallback;
|
|
155
|
+
if (mode === 'docker') {
|
|
156
|
+
const compose = await readComposeText(projectDir);
|
|
157
|
+
const botContainer = getBotContainerName(projectDir);
|
|
158
|
+
const routerContainer = parseComposeServiceContainerName(compose, '9router') || '9router';
|
|
159
|
+
const [openclawOut, routerOut, nodeOut] = await Promise.all([
|
|
160
|
+
runCapture('docker', ['exec', botContainer, 'node', '-e', "const fs=require('fs');const p='/usr/local/lib/node_modules/openclaw/package.json';process.stdout.write(fs.existsSync(p)?String(JSON.parse(fs.readFileSync(p,'utf8')).version||''):'')"], { shell: false }),
|
|
161
|
+
runCapture('docker', ['exec', routerContainer, 'node', '-e', "fetch('http://localhost:20128/api/version').then(async r=>{const d=await r.json().catch(()=>({}));process.stdout.write(String(d.currentVersion||''));}).catch(()=>process.stdout.write(''))"], { shell: false }),
|
|
162
|
+
runCapture('docker', ['exec', botContainer, 'node', '--version'], { shell: false }),
|
|
163
|
+
]);
|
|
164
|
+
return {
|
|
165
|
+
openclaw: String(openclawOut.stdout || '').trim(),
|
|
166
|
+
nineRouter: String(routerOut.stdout || '').trim(),
|
|
167
|
+
node: String(nodeOut.stdout || '').trim(),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const current = await getCurrentRuntimeVersions();
|
|
171
|
+
return {
|
|
172
|
+
openclaw: current.openclaw || '',
|
|
173
|
+
nineRouter: current.nineRouter || '',
|
|
174
|
+
node: current.node || process.version || '',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runStreamed(cmd, args, opts = {}) {
|
|
179
|
+
sendLog(`$ ${cmd} ${args.join(' ')}`);
|
|
180
|
+
const child = spawn(cmd, args, {
|
|
181
|
+
cwd: opts.cwd,
|
|
182
|
+
shell: opts.shell ?? process.platform === 'win32',
|
|
183
|
+
windowsHide: opts.windowsHide ?? true,
|
|
184
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
185
|
+
});
|
|
186
|
+
child.stdout.on('data', (d) => sendLog(String(d).trimEnd()));
|
|
187
|
+
child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
|
|
188
|
+
child.on('error', (err) => sendLog(`ERROR: ${err.message}`));
|
|
189
|
+
child.on('close', (code) => sendLog(`${cmd} exited ${code}`));
|
|
190
|
+
return child.pid;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function runStreamedToLogFile(cmd, args, logFile, opts = {}) {
|
|
194
|
+
sendLog(`$ ${cmd} ${args.join(' ')}`);
|
|
195
|
+
const child = spawn(cmd, args, {
|
|
196
|
+
cwd: opts.cwd,
|
|
197
|
+
shell: opts.shell ?? process.platform === 'win32',
|
|
198
|
+
windowsHide: opts.windowsHide ?? true,
|
|
199
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
200
|
+
});
|
|
201
|
+
let offset = 0;
|
|
202
|
+
const poll = setInterval(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const data = opts.readLogFile ? await opts.readLogFile(logFile) : (existsSync(logFile) ? await fsp.readFile(logFile, 'utf8') : '');
|
|
205
|
+
if (data.length <= offset) return;
|
|
206
|
+
const chunk = data.slice(offset);
|
|
207
|
+
offset = data.length;
|
|
208
|
+
for (const line of chunk.split(/\r?\n/).filter(Boolean)) sendLog(line);
|
|
209
|
+
} catch {}
|
|
210
|
+
}, 700);
|
|
211
|
+
child.on('error', (err) => sendLog(`ERROR: ${err.message}`));
|
|
212
|
+
child.on('close', (code) => { clearInterval(poll); sendLog(`${cmd} exited ${code}`); });
|
|
213
|
+
return child.pid;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function runCapture(cmd, args, opts = {}) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let stdout = '';
|
|
219
|
+
let stderr = '';
|
|
220
|
+
const child = spawn(cmd, args, {
|
|
221
|
+
cwd: opts.cwd,
|
|
222
|
+
shell: opts.shell ?? process.platform === 'win32',
|
|
223
|
+
windowsHide: opts.windowsHide ?? true,
|
|
224
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
225
|
+
});
|
|
226
|
+
let timedOut = false;
|
|
227
|
+
const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
|
|
228
|
+
? setTimeout(() => {
|
|
229
|
+
timedOut = true;
|
|
230
|
+
try { child.kill(); } catch {}
|
|
231
|
+
}, opts.timeout)
|
|
232
|
+
: null;
|
|
233
|
+
child.stdout.on('data', (d) => { stdout += String(d); });
|
|
234
|
+
child.stderr.on('data', (d) => { stderr += String(d); });
|
|
235
|
+
child.on('error', (err) => {
|
|
236
|
+
if (timer) clearTimeout(timer);
|
|
237
|
+
resolve({ code: 1, stdout, stderr: stderr + err.message });
|
|
238
|
+
});
|
|
239
|
+
child.on('close', (code) => {
|
|
240
|
+
if (timer) clearTimeout(timer);
|
|
241
|
+
resolve({ code: timedOut ? 124 : code, stdout, stderr: timedOut ? `${stderr}
|
|
242
|
+
Timed out after ${opts.timeout}ms`.trim() : stderr });
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function safeJoin(root, name) {
|
|
248
|
+
const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
|
|
249
|
+
if (!clean || clean.includes('..')) throw httpError(400, 'Invalid file path');
|
|
250
|
+
const full = resolve(root, clean);
|
|
251
|
+
if (!full.startsWith(resolve(root))) throw httpError(403, 'Path escapes project');
|
|
252
|
+
return full;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function httpError(status, message) {
|
|
256
|
+
const err = new Error(message);
|
|
257
|
+
err.status = status;
|
|
258
|
+
return err;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function slugify(name, fallback = 'bot') {
|
|
262
|
+
return String(name || fallback).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || fallback;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readEnvText(text = '') {
|
|
266
|
+
const out = {};
|
|
267
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
268
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
269
|
+
if (m) out[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function numberFrom(...values) {
|
|
275
|
+
for (const value of values) {
|
|
276
|
+
const n = Number(String(value ?? '').match(/\d{2,5}/)?.[0] || '');
|
|
277
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
278
|
+
}
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readEnvFile(file) {
|
|
283
|
+
return existsSync(file) ? readEnvText(await fsp.readFile(file, 'utf8').catch(() => '')) : {};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function openclawProjectEnv(projectDir) {
|
|
287
|
+
return {
|
|
288
|
+
...process.env,
|
|
289
|
+
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
290
|
+
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
|
|
295
|
+
const out = await runCapture('openclaw', args, {
|
|
296
|
+
cwd: projectDir,
|
|
297
|
+
env: openclawProjectEnv(projectDir),
|
|
298
|
+
shell: false,
|
|
299
|
+
timeout,
|
|
300
|
+
});
|
|
301
|
+
if (out.code !== 0) throw new Error((out.stderr || out.stdout || `openclaw ${args.join(' ')} failed`).trim());
|
|
302
|
+
const text = String(out.stdout || '').trim();
|
|
303
|
+
return text ? JSON.parse(text) : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readComposeText(projectDir) {
|
|
307
|
+
const p = join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
|
|
308
|
+
return existsSync(p) ? await fsp.readFile(p, 'utf8').catch(() => '') : '';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getComposeServiceBlock(compose = '', serviceName = '') {
|
|
312
|
+
const lines = String(compose || '').split(/\r?\n/);
|
|
313
|
+
const start = lines.findIndex((l) => new RegExp(`^\\s{2}${serviceName}:\\s*$`).test(l));
|
|
314
|
+
if (start < 0) return '';
|
|
315
|
+
let end = lines.length;
|
|
316
|
+
for (let i = start + 1; i < lines.length; i++) if (/^\s{2}\S/.test(lines[i])) { end = i; break; }
|
|
317
|
+
return lines.slice(start, end).join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseComposeServiceContainerName(compose = '', serviceName = '') {
|
|
321
|
+
const block = getComposeServiceBlock(compose, serviceName);
|
|
322
|
+
return block.match(/^\s{4}container_name:\s*["']?([^"'\r\n]+)["']?\s*$/m)?.[1]?.trim() || '';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseComposeHostPort(compose = '', containerPort = 0, serviceHint = '') {
|
|
326
|
+
const lines = String(compose || '').split(/\r?\n/);
|
|
327
|
+
let text = lines.join('\n');
|
|
328
|
+
if (serviceHint) {
|
|
329
|
+
const start = lines.findIndex((l) => new RegExp(`^\\s{2}${serviceHint}:\\s*$`).test(l));
|
|
330
|
+
if (start >= 0) {
|
|
331
|
+
let end = lines.length;
|
|
332
|
+
for (let i = start + 1; i < lines.length; i++) if (/^\s{2}\S/.test(lines[i])) { end = i; break; }
|
|
333
|
+
text = lines.slice(start, end).join('\n');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const re = new RegExp(`["']?(?:127\\.0\\.0\\.1:)?(\\d{2,5}):${containerPort || '\\d{2,5}'}["']?`);
|
|
337
|
+
return numberFrom(text.match(re)?.[1]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseBaseUrlPort(baseUrl = '') {
|
|
341
|
+
try {
|
|
342
|
+
const u = new URL(baseUrl);
|
|
343
|
+
return Number(u.port || (u.protocol === 'https:' ? 443 : 80)) || 0;
|
|
344
|
+
} catch {
|
|
345
|
+
return numberFrom(baseUrl);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function detectRuntime(projectDir) {
|
|
350
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
351
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
|
|
352
|
+
let cliGatewayStatus = null;
|
|
353
|
+
let cliGatewayPort = 0;
|
|
354
|
+
let cliRouterPort = 0;
|
|
355
|
+
let syncSource = 'config';
|
|
356
|
+
try {
|
|
357
|
+
cliGatewayStatus = await runOpenclawJson(projectDir, ['gateway', 'status', '--json', '--no-probe'], 15000);
|
|
358
|
+
cliGatewayPort = numberFrom(cliGatewayStatus?.gateway?.port);
|
|
359
|
+
if (cliGatewayPort) syncSource = 'cli';
|
|
360
|
+
} catch {}
|
|
361
|
+
try {
|
|
362
|
+
cliRouterPort = parseBaseUrlPort(await runOpenclawJson(projectDir, ['config', 'get', "models.providers['9router'].baseUrl", '--json'], 8000));
|
|
363
|
+
if (cliRouterPort) syncSource = 'cli';
|
|
364
|
+
} catch {}
|
|
365
|
+
const rootEnv = await readEnvFile(join(projectDir || '', '.env'));
|
|
366
|
+
const dockerEnv = await readEnvFile(join(projectDir || '', 'docker', 'openclaw', '.env'));
|
|
367
|
+
const compose = await readComposeText(projectDir);
|
|
368
|
+
const gatewayPort = numberFrom(
|
|
369
|
+
cliGatewayPort,
|
|
370
|
+
rootEnv.OPENCLAW_GATEWAY_PORT,
|
|
371
|
+
rootEnv.OPENCLAW_PORT,
|
|
372
|
+
dockerEnv.OPENCLAW_GATEWAY_PORT,
|
|
373
|
+
dockerEnv.OPENCLAW_PORT,
|
|
374
|
+
compose.match(/OPENCLAW_GATEWAY_PORT=(\d+)/)?.[1],
|
|
375
|
+
compose.match(/OPENCLAW_PORT=(\d+)/)?.[1],
|
|
376
|
+
parseComposeHostPort(compose, numberFrom(cfg.gateway?.port) || 18789, 'ai-bot'),
|
|
377
|
+
cfg.gateway?.port,
|
|
378
|
+
18789,
|
|
379
|
+
) || 18789;
|
|
380
|
+
const providerBase = cfg.models?.providers?.['9router']?.baseUrl || '';
|
|
381
|
+
const providerPort = cliRouterPort || parseBaseUrlPort(providerBase);
|
|
382
|
+
const routerContainerPort = numberFrom(compose.match(/(?:PORT=|-p\s+)(\d{2,5})/)?.[1], providerPort, 20128) || 20128;
|
|
383
|
+
const routerPort = numberFrom(
|
|
384
|
+
parseComposeHostPort(compose, routerContainerPort, '9router'),
|
|
385
|
+
/^(https?:\/\/)?(localhost|127\.0\.0\.1|host\.docker\.internal|9router)(:|\/)/i.test(providerBase) ? providerPort : 0,
|
|
386
|
+
20128,
|
|
387
|
+
) || 20128;
|
|
388
|
+
if (syncSource !== 'cli' && compose) syncSource = 'compose';
|
|
389
|
+
return {
|
|
390
|
+
gatewayPort,
|
|
391
|
+
routerPort,
|
|
392
|
+
gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
|
|
393
|
+
routerUrl: `http://127.0.0.1:${routerPort}`,
|
|
394
|
+
mode: existsSync(join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml')) ? 'docker' : 'native',
|
|
395
|
+
cliGatewayStatus,
|
|
396
|
+
syncSource,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function syncRuntimeState(projectDir) {
|
|
401
|
+
if (!projectDir || !existsSync(join(projectDir, '.openclaw', 'openclaw.json'))) return;
|
|
402
|
+
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
403
|
+
const rt = await detectRuntime(projectDir).catch(() => null);
|
|
404
|
+
if (!rt) return;
|
|
405
|
+
state.projectDir = projectDir;
|
|
406
|
+
state.gatewayPort = rt.gatewayPort;
|
|
407
|
+
state.routerPort = rt.routerPort;
|
|
408
|
+
state.gatewayUrl = rt.gatewayUrl;
|
|
409
|
+
state.routerUrl = rt.routerUrl;
|
|
410
|
+
state.mode = state.mode || rt.mode;
|
|
411
|
+
state.syncSource = rt.syncSource || 'config';
|
|
412
|
+
state.installed = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function uniqueSlug(base, used) {
|
|
416
|
+
let out = base;
|
|
417
|
+
let i = 2;
|
|
418
|
+
while (used.has(out)) out = `${base}-${i++}`;
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function uniqueDisplayName(base, used) {
|
|
423
|
+
const clean = String(base || 'OpenClaw Bot').trim() || 'OpenClaw Bot';
|
|
424
|
+
const taken = new Set(Array.from(used || []).map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
|
425
|
+
if (!taken.has(clean.toLowerCase())) return clean;
|
|
426
|
+
let i = 2;
|
|
427
|
+
let out = `${clean} ${i}`;
|
|
428
|
+
while (taken.has(out.toLowerCase())) out = `${clean} ${++i}`;
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function parseIdentityFields(content = '') {
|
|
433
|
+
const out = {};
|
|
434
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
const m = line.match(/^\s*-\s*\*\*(?:Tên|Name)\s*:\*\*\s*(.+?)\s*$/i);
|
|
437
|
+
if (m) out.name = m[1].trim();
|
|
438
|
+
const r = line.match(/^\s*-\s*\*\*(?:Vai trò|Role)\s*:\*\*\s*(.+?)\s*$/i);
|
|
439
|
+
if (r) out.role = r[1].trim();
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function usableIdentityName(name = '') {
|
|
445
|
+
const clean = String(name || '').trim();
|
|
446
|
+
if (clean && clean.length <= 60 && !/[*_"()]/.test(clean)) return clean;
|
|
447
|
+
const bold = clean.match(/\*\*([^*]{1,40})\*\*/)?.[1]?.trim();
|
|
448
|
+
return bold && !/[*_"()]/.test(bold) ? bold : '';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function workspaceRelForAgent(agent, cfg = {}, projectDir = '') {
|
|
452
|
+
const hasOwnWorkspace = !!agent?.workspace;
|
|
453
|
+
const raw = agent?.workspace || cfg.agents?.defaults?.workspace || '';
|
|
454
|
+
const s = String(raw || '').replace(/\\/g, '/');
|
|
455
|
+
let resolved = '';
|
|
456
|
+
const m = s.match(/(?:^|\/)\.openclaw\/(.+)$/);
|
|
457
|
+
if (m) resolved = m[1].replace(/^\/+/, '');
|
|
458
|
+
else if (s.startsWith('.openclaw/')) resolved = s.replace(/^\.openclaw\//, '');
|
|
459
|
+
else if (s.startsWith('workspace')) resolved = s;
|
|
460
|
+
else resolved = `workspace-${agent?.id || 'workspace'}`;
|
|
461
|
+
// When workspace came from defaults (shared), prefer per-agent dir if it exists
|
|
462
|
+
if (!hasOwnWorkspace && agent?.id && resolved !== `workspace-${agent.id}`) {
|
|
463
|
+
const perAgent = `workspace-${agent.id}`;
|
|
464
|
+
if (projectDir) {
|
|
465
|
+
if (existsSync(join(projectDir, '.openclaw', perAgent))) return perAgent;
|
|
466
|
+
}
|
|
467
|
+
if (projectDir && !existsSync(join(projectDir, '.openclaw', resolved))) return perAgent;
|
|
468
|
+
}
|
|
469
|
+
return resolved;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function readAgentIdentity(projectDir, agent) {
|
|
473
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
474
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
|
|
475
|
+
const rel = workspaceRelForAgent(agent, cfg, projectDir);
|
|
476
|
+
if (!rel) return {};
|
|
477
|
+
const file = join(projectDir, '.openclaw', rel, 'IDENTITY.md');
|
|
478
|
+
if (!existsSync(file)) return {};
|
|
479
|
+
return parseIdentityFields(await fsp.readFile(file, 'utf8').catch(() => ''));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function ensureConfigShape(cfg) {
|
|
483
|
+
if (!cfg || typeof cfg !== 'object') throw httpError(400, 'Invalid openclaw.json');
|
|
484
|
+
cfg.agents = cfg.agents || {};
|
|
485
|
+
cfg.agents.defaults = cfg.agents.defaults || { model: { primary: DEFAULT_MODEL, fallbacks: [] } };
|
|
486
|
+
cfg.agents.defaults.model = cfg.agents.defaults.model || { primary: DEFAULT_MODEL, fallbacks: [] };
|
|
487
|
+
if (!cfg.agents.defaults.model.primary || cfg.agents.defaults.model.primary === '9router/smart-route' || cfg.agents.defaults.model.primary === 'openai/smart-route') cfg.agents.defaults.model.primary = DEFAULT_MODEL;
|
|
488
|
+
cfg.agents.list = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
|
|
489
|
+
for (const agent of cfg.agents.list) {
|
|
490
|
+
if (agent && typeof agent === 'object') {
|
|
491
|
+
delete agent.channel;
|
|
492
|
+
delete agent.role;
|
|
493
|
+
delete agent.desc;
|
|
494
|
+
delete agent.description;
|
|
495
|
+
delete agent.persona;
|
|
496
|
+
}
|
|
497
|
+
agent.model = agent.model || { primary: cfg.agents.defaults.model.primary, fallbacks: [] };
|
|
498
|
+
if (!agent.model.primary || agent.model.primary === '9router/smart-route' || agent.model.primary === 'openai/smart-route') agent.model.primary = DEFAULT_MODEL;
|
|
499
|
+
}
|
|
500
|
+
cfg.models = cfg.models || { mode: 'merge', providers: {} };
|
|
501
|
+
cfg.models.providers = cfg.models.providers || {};
|
|
502
|
+
if (!cfg.models.providers['9router']) cfg.models.providers['9router'] = cfg.models.providers.openai || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : undefined);
|
|
503
|
+
if (cfg.models.providers.openai?.baseUrl?.includes('9router')) delete cfg.models.providers.openai;
|
|
504
|
+
cfg.channels = cfg.channels || {};
|
|
505
|
+
cfg.bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
|
506
|
+
cfg.plugins = cfg.plugins || { entries: { 'memory-core': { config: { dreaming: { enabled: false } } } } };
|
|
507
|
+
// Preserve plugins.allow — needed for non-bundled plugins like zalouser, zalo-mod
|
|
508
|
+
if (!cfg.plugins.allow) cfg.plugins.allow = [];
|
|
509
|
+
cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
|
|
510
|
+
return cfg;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function ensureTelegramChannel(cfg) {
|
|
514
|
+
cfg.channels.telegram = cfg.channels.telegram || {};
|
|
515
|
+
Object.assign(cfg.channels.telegram, {
|
|
516
|
+
enabled: true,
|
|
517
|
+
defaultAccount: cfg.channels.telegram.defaultAccount || 'default',
|
|
518
|
+
dmPolicy: cfg.channels.telegram.dmPolicy || 'open',
|
|
519
|
+
allowFrom: cfg.channels.telegram.allowFrom || ['*'],
|
|
520
|
+
replyToMode: cfg.channels.telegram.replyToMode || 'first',
|
|
521
|
+
reactionLevel: cfg.channels.telegram.reactionLevel || 'minimal',
|
|
522
|
+
actions: cfg.channels.telegram.actions || { sendMessage: true, reactions: true },
|
|
523
|
+
accounts: cfg.channels.telegram.accounts || {},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function ensureZaloUserChannel(cfg) {
|
|
528
|
+
cfg.channels.zalouser = cfg.channels.zalouser || {
|
|
529
|
+
enabled: true,
|
|
530
|
+
defaultAccount: 'default',
|
|
531
|
+
accounts: {
|
|
532
|
+
default: {
|
|
533
|
+
enabled: true,
|
|
534
|
+
profile: 'default',
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
dmPolicy: 'open',
|
|
538
|
+
groupPolicy: 'open',
|
|
539
|
+
historyLimit: 50,
|
|
540
|
+
groups: {
|
|
541
|
+
'*': { enabled: true, requireMention: false },
|
|
542
|
+
},
|
|
543
|
+
allowFrom: ['*'],
|
|
544
|
+
groupAllowFrom: ['*'],
|
|
545
|
+
};
|
|
546
|
+
// Ensure zalouser is in plugins.entries (plugins.allow is deprecated)
|
|
547
|
+
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
548
|
+
cfg.plugins.entries.zalouser = cfg.plugins.entries.zalouser || { enabled: true };
|
|
549
|
+
cfg.plugins.allow = cfg.plugins.allow || [];
|
|
550
|
+
if (!cfg.plugins.allow.includes('zalouser')) cfg.plugins.allow.push('zalouser');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function ensureZaloApiChannel(cfg, token) {
|
|
554
|
+
cfg.channels.zalo = cfg.channels.zalo || {};
|
|
555
|
+
Object.assign(cfg.channels.zalo, {
|
|
556
|
+
enabled: true,
|
|
557
|
+
provider: cfg.channels.zalo.provider || 'official_account',
|
|
558
|
+
botToken: token || cfg.channels.zalo.botToken || '<your_zalo_bot_token>',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function readProjectConfig(projectDir) {
|
|
563
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
564
|
+
if (!projectDir || !existsSync(cfgPath)) return null;
|
|
565
|
+
return { cfgPath, cfg: null };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Read 9Router endpoint API key from the apiKeys table (NOT providerConnections which stores Gemini/OpenAI keys)
|
|
569
|
+
function read9RouterEndpointApiKey(sqlitePath) {
|
|
570
|
+
if (!sqlitePath || !existsSync(sqlitePath)) return '';
|
|
571
|
+
let db;
|
|
572
|
+
try {
|
|
573
|
+
db = new DatabaseSync(sqlitePath, { readOnly: true });
|
|
574
|
+
const rows = db.prepare(`
|
|
575
|
+
SELECT key FROM apiKeys
|
|
576
|
+
WHERE isActive = 1
|
|
577
|
+
ORDER BY createdAt DESC
|
|
578
|
+
LIMIT 1
|
|
579
|
+
`).all();
|
|
580
|
+
return String(rows[0]?.key || '').trim();
|
|
581
|
+
} catch {
|
|
582
|
+
return '';
|
|
583
|
+
} finally {
|
|
584
|
+
try { db?.close(); } catch {}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Keep legacy alias for backward compat
|
|
589
|
+
function read9RouterApiKeyFromSqlite(sqlitePath) {
|
|
590
|
+
return read9RouterEndpointApiKey(sqlitePath);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function read9RouterApiKeyFromDocker(containerName) {
|
|
594
|
+
if (!containerName) return '';
|
|
595
|
+
const script = `
|
|
596
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
597
|
+
let db;
|
|
598
|
+
try {
|
|
599
|
+
db = new DatabaseSync('/root/.9router/db/data.sqlite', { readOnly: true });
|
|
600
|
+
const rows = db.prepare("SELECT key FROM apiKeys WHERE isActive = 1 ORDER BY createdAt DESC LIMIT 1").all();
|
|
601
|
+
process.stdout.write(rows[0] && rows[0].key ? rows[0].key : '');
|
|
602
|
+
} catch (err) {
|
|
603
|
+
process.stderr.write(String(err && err.message || err));
|
|
604
|
+
process.exit(1);
|
|
605
|
+
} finally {
|
|
606
|
+
try { db && db.close(); } catch {}
|
|
607
|
+
}`;
|
|
608
|
+
const out = await runCapture('docker', ['exec', containerName, 'node', '-e', script], { shell: false });
|
|
609
|
+
if (out.code !== 0) return '';
|
|
610
|
+
return String(out.stdout || '').trim();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function create9RouterApiKeyFromDocker(containerName, keyName = 'openclaw-bot') {
|
|
614
|
+
if (!containerName) return '';
|
|
615
|
+
const script = `
|
|
616
|
+
const api = require('/usr/local/lib/node_modules/9router/src/cli/api/client.js');
|
|
617
|
+
api.createApiKey(${JSON.stringify(keyName)}).then((r) => {
|
|
618
|
+
process.stdout.write(JSON.stringify(r || {}));
|
|
619
|
+
}).catch((err) => {
|
|
620
|
+
process.stderr.write(String(err && err.message || err));
|
|
621
|
+
process.exit(1);
|
|
622
|
+
});
|
|
623
|
+
`;
|
|
624
|
+
const out = await runCapture('docker', ['exec', containerName, 'node', '-e', script], { shell: false });
|
|
625
|
+
if (out.code !== 0) return '';
|
|
626
|
+
try {
|
|
627
|
+
const data = JSON.parse(String(out.stdout || '{}'));
|
|
628
|
+
return String(data?.data?.key || '').trim();
|
|
629
|
+
} catch {
|
|
630
|
+
return '';
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function resolveProject9RouterApiKey(projectDir, cfg = null) {
|
|
635
|
+
const configApiKey = String(cfg?.models?.providers?.['9router']?.apiKey || '').trim();
|
|
636
|
+
if (configApiKey && configApiKey !== 'sk-no-key') return configApiKey;
|
|
637
|
+
const compose = await readComposeText(projectDir);
|
|
638
|
+
if (compose) {
|
|
639
|
+
const containerName = parseComposeServiceContainerName(compose, '9router') || '9router';
|
|
640
|
+
const dockerApiKey = await read9RouterApiKeyFromDocker(containerName);
|
|
641
|
+
if (dockerApiKey) return dockerApiKey;
|
|
642
|
+
const createdApiKey = await create9RouterApiKeyFromDocker(containerName, `openclaw-${slugify(basename(projectDir || 'openclaw')) || 'bot'}`).catch(() => '');
|
|
643
|
+
if (createdApiKey) return createdApiKey;
|
|
644
|
+
}
|
|
645
|
+
const nativeApiKey = read9RouterApiKeyFromSqlite(join(projectDir || '', '.9router', 'db', 'data.sqlite'));
|
|
646
|
+
if (nativeApiKey) return nativeApiKey;
|
|
647
|
+
const homeApiKey = read9RouterApiKeyFromSqlite(join(os.homedir(), '.9router', 'db', 'data.sqlite'));
|
|
648
|
+
if (homeApiKey) return homeApiKey;
|
|
649
|
+
return '';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function applyResolved9RouterApiKey(projectDir, cfg = null) {
|
|
653
|
+
if (!projectDir) return '';
|
|
654
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
655
|
+
if (!existsSync(cfgPath)) return '';
|
|
656
|
+
const current = cfg || ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
657
|
+
const apiKey = await resolveProject9RouterApiKey(projectDir, current);
|
|
658
|
+
if (!apiKey) return '';
|
|
659
|
+
current.models = current.models || { mode: 'merge', providers: {} };
|
|
660
|
+
current.models.providers = current.models.providers || {};
|
|
661
|
+
current.models.providers['9router'] = current.models.providers['9router'] || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : {});
|
|
662
|
+
if (current.models.providers['9router'].apiKey !== apiKey) {
|
|
663
|
+
current.models.providers['9router'].apiKey = apiKey;
|
|
664
|
+
await fsp.writeFile(cfgPath, JSON.stringify(current, null, 2), 'utf8');
|
|
665
|
+
}
|
|
666
|
+
return apiKey;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function readBotCredentials(projectDir) {
|
|
670
|
+
const found = readProjectConfig(projectDir);
|
|
671
|
+
if (!found) return { openclawToken: '', nineRouterApiKey: '' };
|
|
672
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(found.cfgPath, 'utf8')));
|
|
673
|
+
return {
|
|
674
|
+
openclawToken: cfg.gateway?.auth?.token || '',
|
|
675
|
+
nineRouterApiKey: await resolveProject9RouterApiKey(projectDir, cfg),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function updateBotCredentials(projectDir, body = {}) {
|
|
680
|
+
const found = readProjectConfig(projectDir);
|
|
681
|
+
if (!found) throw httpError(400, 'Install project not found');
|
|
682
|
+
const raw = await fsp.readFile(found.cfgPath, 'utf8');
|
|
683
|
+
const cfg = ensureConfigShape(JSON.parse(raw));
|
|
684
|
+
const nineRouterApiKey = String(body.nineRouterApiKey || '').trim();
|
|
685
|
+
if (Object.prototype.hasOwnProperty.call(body, 'nineRouterApiKey')) {
|
|
686
|
+
cfg.models = cfg.models || { mode: 'merge', providers: {} };
|
|
687
|
+
cfg.models.providers = cfg.models.providers || {};
|
|
688
|
+
cfg.models.providers['9router'] = cfg.models.providers['9router'] || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : {});
|
|
689
|
+
cfg.models.providers['9router'].apiKey = nineRouterApiKey;
|
|
690
|
+
await appendEnvValue(projectDir, 'NINE_ROUTER_API_KEY', nineRouterApiKey);
|
|
691
|
+
}
|
|
692
|
+
await fsp.writeFile(found.cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
693
|
+
return readBotCredentials(projectDir);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function appendEnvValue(projectDir, key, value) {
|
|
697
|
+
const envPath = join(projectDir, '.env');
|
|
698
|
+
const line = `${key}=${value || ''}`;
|
|
699
|
+
let env = existsSync(envPath) ? await fsp.readFile(envPath, 'utf8') : '';
|
|
700
|
+
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
701
|
+
env = re.test(env) ? env.replace(re, line) : `${env.replace(/\s*$/, '\n')}${line}\n`;
|
|
702
|
+
await fsp.writeFile(envPath, env, 'utf8');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function validateOpenclawConfig(cfg) {
|
|
706
|
+
if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
|
|
707
|
+
for (const a of cfg.agents.list) {
|
|
708
|
+
if (!a.id) throw httpError(500, `Invalid agent entry: ${a.id || '(missing id)'}`);
|
|
709
|
+
}
|
|
710
|
+
if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
|
|
711
|
+
|
|
712
|
+
// Self-healing: Garbage collect any orphaned telegram accounts that are no longer bound to any active agent
|
|
713
|
+
if (cfg.channels?.telegram?.accounts) {
|
|
714
|
+
const boundAccountIds = new Set(
|
|
715
|
+
(cfg.bindings || []).map((b) => b.match?.accountId).filter(Boolean)
|
|
716
|
+
);
|
|
717
|
+
for (const accId of Object.keys(cfg.channels.telegram.accounts)) {
|
|
718
|
+
if (!boundAccountIds.has(accId)) {
|
|
719
|
+
delete cfg.channels.telegram.accounts[accId];
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function mapAgentChannel(agent, cfg) {
|
|
726
|
+
const agentId = typeof agent === 'string' ? agent : agent?.id;
|
|
727
|
+
if (agent && typeof agent === 'object' && ['telegram', 'zalo-personal', 'zalo-bot'].includes(agent.channel)) return agent.channel;
|
|
728
|
+
const binding = (cfg.bindings || []).find((b) => b.agentId === agentId);
|
|
729
|
+
const ch = binding?.match?.channel;
|
|
730
|
+
if (ch === 'zalouser') return 'zalo-personal';
|
|
731
|
+
if (ch === 'zalo') return 'zalo-bot';
|
|
732
|
+
if (ch === 'telegram') return 'telegram';
|
|
733
|
+
if (cfg.channels?.telegram && agentId) return 'telegram';
|
|
734
|
+
return 'unknown';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function mapAgentChannels(agent, cfg) {
|
|
738
|
+
if (agent?.channel && ['telegram', 'zalo-personal', 'zalo-bot'].includes(agent.channel)) return [agent.channel];
|
|
739
|
+
const channels = (cfg.bindings || [])
|
|
740
|
+
.filter((b) => b.agentId === agent?.id)
|
|
741
|
+
.map((b) => b.match?.channel === 'zalouser' ? 'zalo-personal' : b.match?.channel === 'zalo' ? 'zalo-bot' : b.match?.channel)
|
|
742
|
+
.filter((ch) => ['telegram', 'zalo-personal', 'zalo-bot'].includes(ch));
|
|
743
|
+
if (channels.length) return Array.from(new Set(channels));
|
|
744
|
+
const enabled = [];
|
|
745
|
+
if (cfg.channels?.telegram?.enabled) enabled.push('telegram');
|
|
746
|
+
if (cfg.channels?.zalouser?.enabled) enabled.push('zalo-personal');
|
|
747
|
+
if (cfg.channels?.zalo?.enabled) enabled.push('zalo-bot');
|
|
748
|
+
return enabled.length === 1 ? enabled : [mapAgentChannel(agent, cfg)];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function listConfiguredBots(projectDir) {
|
|
752
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
753
|
+
if (!projectDir || !existsSync(cfgPath)) return [];
|
|
754
|
+
const raw = await fsp.readFile(cfgPath, 'utf8');
|
|
755
|
+
const cfg = ensureConfigShape(JSON.parse(raw));
|
|
756
|
+
const normalized = JSON.stringify(cfg, null, 2) + '\n';
|
|
757
|
+
if (normalized !== raw) await fsp.writeFile(cfgPath, normalized, 'utf8');
|
|
758
|
+
const rows = await Promise.all(cfg.agents.list.map(async (agent) => {
|
|
759
|
+
const identity = await readAgentIdentity(projectDir, agent);
|
|
760
|
+
const hasOwnWorkspace = !!agent.workspace;
|
|
761
|
+
const identityName = usableIdentityName(identity.name);
|
|
762
|
+
return mapAgentChannels(agent, cfg).map((channel) => ({
|
|
763
|
+
id: agent.id,
|
|
764
|
+
name: (hasOwnWorkspace ? identityName : agent.name) || agent.name || identityName || agent.id,
|
|
765
|
+
role: identity.role || agent.role || agent.desc || agent.description || '',
|
|
766
|
+
channel,
|
|
767
|
+
workspace: agent.workspace || `.openclaw/${workspaceRelForAgent(agent, cfg, projectDir)}`,
|
|
768
|
+
agentDir: agent.agentDir,
|
|
769
|
+
}));
|
|
770
|
+
}));
|
|
771
|
+
return rows.flat();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function rmInside(root, target) {
|
|
775
|
+
const rootFull = resolve(root);
|
|
776
|
+
const targetFull = resolve(root, target);
|
|
777
|
+
if (targetFull === rootFull || !targetFull.startsWith(rootFull + '\\') && !targetFull.startsWith(rootFull + '/')) {
|
|
778
|
+
throw httpError(403, 'Delete path escapes project');
|
|
779
|
+
}
|
|
780
|
+
await fsp.rm(targetFull, { recursive: true, force: true }).catch(() => {});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function deleteBotInProject(projectDir, agentId) {
|
|
784
|
+
if (!projectDir) throw httpError(400, 'Install project not found');
|
|
785
|
+
const cleanId = slugify(agentId, '');
|
|
786
|
+
if (!cleanId || cleanId !== agentId) throw httpError(400, 'Invalid bot id');
|
|
787
|
+
const openclawHome = join(projectDir, '.openclaw');
|
|
788
|
+
const cfgPath = join(openclawHome, 'openclaw.json');
|
|
789
|
+
if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
|
|
790
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
791
|
+
const agent = cfg.agents.list.find((a) => a.id === agentId);
|
|
792
|
+
if (!agent) throw httpError(404, 'Bot not found');
|
|
793
|
+
|
|
794
|
+
const removedBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
|
|
795
|
+
cfg.agents.list = cfg.agents.list.filter((a) => a.id !== agentId);
|
|
796
|
+
cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
|
|
797
|
+
if (cfg.tools?.agentToAgent?.allow) {
|
|
798
|
+
cfg.tools.agentToAgent.allow = cfg.tools.agentToAgent.allow.filter((id) => id !== agentId);
|
|
799
|
+
if (cfg.tools.agentToAgent.allow.length < 2) delete cfg.tools.agentToAgent;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
for (const binding of removedBindings) {
|
|
803
|
+
const accountId = binding.match?.accountId;
|
|
804
|
+
if (binding.match?.channel === 'telegram' && accountId && cfg.channels?.telegram?.accounts) delete cfg.channels.telegram.accounts[accountId];
|
|
805
|
+
}
|
|
806
|
+
if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
|
|
807
|
+
|
|
808
|
+
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
809
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
810
|
+
|
|
811
|
+
// Also clear bot tokens in .env files if deleting the primary bot
|
|
812
|
+
if (agentId === 'bot') {
|
|
813
|
+
const envPath = join(projectDir, '.env');
|
|
814
|
+
if (existsSync(envPath)) {
|
|
815
|
+
let envContent = await fsp.readFile(envPath, 'utf8');
|
|
816
|
+
envContent = envContent
|
|
817
|
+
.replace(/TELEGRAM_BOT_TOKEN=.*/g, 'TELEGRAM_BOT_TOKEN=')
|
|
818
|
+
await fsp.writeFile(envPath, envContent, 'utf8');
|
|
819
|
+
|
|
820
|
+
const dockerEnv = join(projectDir, 'docker', 'openclaw', '.env');
|
|
821
|
+
if (existsSync(dockerEnv)) {
|
|
822
|
+
await fsp.writeFile(dockerEnv, envContent, 'utf8');
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const workspace = workspaceRelForAgent(agent, cfg, projectDir);
|
|
828
|
+
if (workspace) await rmInside(openclawHome, workspace);
|
|
829
|
+
await rmInside(projectDir, join('agents', agentId));
|
|
830
|
+
await rmInside(openclawHome, join('agents', agentId));
|
|
831
|
+
|
|
832
|
+
return { ok: true, agentId };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function portStatus(port) {
|
|
836
|
+
return new Promise((resolve) => {
|
|
837
|
+
const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 650 });
|
|
838
|
+
sock.on('connect', () => { sock.destroy(); resolve('online'); });
|
|
839
|
+
sock.on('timeout', () => { sock.destroy(); resolve('offline'); });
|
|
840
|
+
sock.on('error', () => resolve('offline'));
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function buildBotStatus() {
|
|
845
|
+
if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
|
|
846
|
+
const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
|
|
847
|
+
portStatus(state.gatewayPort || 18789).catch(() => state.installed ? 'unknown' : 'offline'),
|
|
848
|
+
portStatus(state.routerPort || 20128).catch(() => state.installed ? 'unknown' : 'offline'),
|
|
849
|
+
listConfiguredBots(state.projectDir).catch(() => []),
|
|
850
|
+
resolveProjectRuntimeVersions(state.projectDir, state.mode).catch(() => ({ openclaw: '', nineRouter: '', node: process.version || '' })),
|
|
851
|
+
]);
|
|
852
|
+
const credentials = await readBotCredentials(state.projectDir).catch(() => ({ openclawToken: '', nineRouterApiKey: '' }));
|
|
853
|
+
return { ...state, gatewayStatus, routerStatus, bots, credentials, runtimeVersions };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
857
|
+
if (!projectDir) throw httpError(400, 'Install project not found');
|
|
858
|
+
const channel = body.channel || 'telegram';
|
|
859
|
+
if (!['telegram', 'zalo-personal', 'zalo-bot'].includes(channel)) throw httpError(400, 'Unsupported channel');
|
|
860
|
+
const token = String(body.token || '').trim();
|
|
861
|
+
if ((channel === 'telegram' || channel === 'zalo-bot') && !token) throw httpError(400, 'Token is required for this channel');
|
|
862
|
+
|
|
863
|
+
const requestedBotName = String(body.botName || '').trim() || 'OpenClaw Bot';
|
|
864
|
+
const botDesc = String(body.role || body.botDesc || '').trim() || 'Personal OpenClaw assistant';
|
|
865
|
+
const persona = String(body.personality || body.persona || '').trim();
|
|
866
|
+
const emoji = String(body.emoji || '').trim();
|
|
867
|
+
const userName = String(body.userName || '').trim();
|
|
868
|
+
const userDesc = String(body.userDescription || body.userDesc || '').trim();
|
|
869
|
+
const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- **Mô tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
|
|
870
|
+
|
|
871
|
+
const openclawHome = join(projectDir, '.openclaw');
|
|
872
|
+
await fsp.mkdir(openclawHome, { recursive: true });
|
|
873
|
+
const cfgPath = join(openclawHome, 'openclaw.json');
|
|
874
|
+
const cfg = ensureConfigShape(existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8')) : buildOpenclawJson({
|
|
875
|
+
botName: requestedBotName,
|
|
876
|
+
channelKey: channel,
|
|
877
|
+
providerKey: '9router',
|
|
878
|
+
deployMode: runtime.mode || state.mode || 'docker',
|
|
879
|
+
osChoice: runtime.os || state.os || detectOs(),
|
|
880
|
+
selectedSkills: [],
|
|
881
|
+
skills: dataExport.SKILLS || [],
|
|
882
|
+
agentMetas: [],
|
|
883
|
+
}));
|
|
884
|
+
|
|
885
|
+
const existingAgentCount = cfg.agents.list.length;
|
|
886
|
+
const used = new Set(cfg.agents.list.map((a) => a.id));
|
|
887
|
+
const botName = uniqueDisplayName(requestedBotName, new Set(cfg.agents.list.map((a) => a.name || a.id)));
|
|
888
|
+
const agentId = uniqueSlug(slugify(botName), used);
|
|
889
|
+
const workspaceDir = `workspace-${agentId}`;
|
|
890
|
+
const model = cfg.agents.defaults?.model?.primary || cfg.agents.list[0]?.model?.primary || DEFAULT_MODEL;
|
|
891
|
+
cfg.agents.list.push({
|
|
892
|
+
id: agentId,
|
|
893
|
+
name: botName,
|
|
894
|
+
workspace: `/root/project/.openclaw/${workspaceDir}`,
|
|
895
|
+
agentDir: `agents/${agentId}/agent`,
|
|
896
|
+
model: { primary: model === '9router/smart-route' || model === 'openai/smart-route' ? DEFAULT_MODEL : model, fallbacks: [] },
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
let accountId = 'default';
|
|
900
|
+
let warning = '';
|
|
901
|
+
if (channel === 'telegram') {
|
|
902
|
+
ensureTelegramChannel(cfg);
|
|
903
|
+
const accounts = cfg.channels.telegram.accounts || {};
|
|
904
|
+
// Use 'default' for the first telegram account, agentId for subsequent ones
|
|
905
|
+
const existingTelegramAccounts = Object.keys(accounts).filter((k) => accounts[k]?.botToken && accounts[k].botToken !== '<your_bot_token>');
|
|
906
|
+
accountId = existingTelegramAccounts.length === 0 ? 'default' : agentId;
|
|
907
|
+
accounts[accountId] = { botToken: token };
|
|
908
|
+
cfg.channels.telegram.accounts = accounts;
|
|
909
|
+
cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
|
|
910
|
+
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
|
|
911
|
+
await appendEnvValue(projectDir, accountId === 'default' ? 'TELEGRAM_BOT_TOKEN' : `TELEGRAM_BOT_TOKEN_${agentId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`, token);
|
|
912
|
+
} else if (channel === 'zalo-personal') {
|
|
913
|
+
ensureZaloUserChannel(cfg);
|
|
914
|
+
const hasZaloBinding = cfg.bindings.some((b) => b.match?.channel === 'zalouser');
|
|
915
|
+
if (!hasZaloBinding) cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
|
|
916
|
+
else warning = 'Zalo User already has a channel binding; new agent created, route manually if needed.';
|
|
917
|
+
} else {
|
|
918
|
+
ensureZaloApiChannel(cfg, token);
|
|
919
|
+
const hasZaloApiBinding = cfg.bindings.some((b) => b.match?.channel === 'zalo');
|
|
920
|
+
if (!hasZaloApiBinding) cfg.bindings.push({ agentId, match: { channel: 'zalo' } });
|
|
921
|
+
await appendEnvValue(projectDir, 'ZALO_BOT_TOKEN', token);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (cfg.agents.list.length > 1) {
|
|
925
|
+
cfg.tools.agentToAgent = { enabled: true, allow: cfg.agents.list.map((a) => a.id) };
|
|
926
|
+
}
|
|
927
|
+
validateOpenclawConfig(cfg);
|
|
928
|
+
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
929
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
930
|
+
|
|
931
|
+
const files = buildWorkspaceFileMap({
|
|
932
|
+
isVi: true,
|
|
933
|
+
botName,
|
|
934
|
+
botDesc,
|
|
935
|
+
persona,
|
|
936
|
+
emoji,
|
|
937
|
+
userInfo,
|
|
938
|
+
agentWorkspaceDir: workspaceDir,
|
|
939
|
+
workspacePath: `.openclaw/${workspaceDir}`,
|
|
940
|
+
hasZaloMod: channel === 'zalo-personal',
|
|
941
|
+
});
|
|
942
|
+
const wsRoot = join(openclawHome, workspaceDir);
|
|
943
|
+
for (const [name, content] of Object.entries(files)) {
|
|
944
|
+
await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
|
|
945
|
+
await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return { ok: true, agentId, accountId, channel, workspace: `.openclaw/${workspaceDir}`, warning };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function updateBotInProject(projectDir, agentId, body = {}, runtime = {}) {
|
|
952
|
+
if (!projectDir) throw httpError(400, 'Install project not found');
|
|
953
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
954
|
+
if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
|
|
955
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
956
|
+
const agent = cfg.agents.list.find((a) => a.id === agentId);
|
|
957
|
+
if (!agent) throw httpError(404, 'Bot not found');
|
|
958
|
+
|
|
959
|
+
const channel = body.channel || (cfg.bindings || []).find((b) => b.agentId === agentId)?.match?.channel || 'telegram';
|
|
960
|
+
const token = String(body.token || '').trim();
|
|
961
|
+
const botName = uniqueDisplayName(String(body.botName || agent.name || agentId).trim() || agent.name || agentId, new Set(cfg.agents.list.filter((a) => a.id !== agentId).map((a) => a.name || a.id)));
|
|
962
|
+
const botDesc = String(body.role || body.botDesc || agent.role || agent.desc || agent.description || '').trim();
|
|
963
|
+
const persona = String(body.personality || body.persona || '').trim();
|
|
964
|
+
const emoji = String(body.emoji || '').trim();
|
|
965
|
+
const userName = String(body.userName || '').trim();
|
|
966
|
+
const userDesc = String(body.userDescription || body.userDesc || '').trim();
|
|
967
|
+
const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- **Mô tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
|
|
968
|
+
const workspaceDir = workspaceRelForAgent(agent, cfg, projectDir) || `workspace-${agentId}`;
|
|
969
|
+
agent.workspace = `/root/project/.openclaw/${workspaceDir}`;
|
|
970
|
+
agent.agentDir = `agents/${agentId}/agent`;
|
|
971
|
+
|
|
972
|
+
// Find the existing accountId from bindings BEFORE removing them
|
|
973
|
+
const existingBinding = (cfg.bindings || []).find((b) => b.agentId === agentId && b.match?.channel === 'telegram');
|
|
974
|
+
const existingAccountId = existingBinding?.match?.accountId || null;
|
|
975
|
+
cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
|
|
976
|
+
if (channel === 'telegram') {
|
|
977
|
+
ensureTelegramChannel(cfg);
|
|
978
|
+
const accounts = cfg.channels.telegram.accounts || {};
|
|
979
|
+
// Preserve existing accountId; for first/only bot use 'default', otherwise agentId
|
|
980
|
+
const existingTelegramAccounts = Object.keys(accounts).filter((k) => accounts[k]?.botToken);
|
|
981
|
+
const accountId = existingAccountId || (existingTelegramAccounts.length === 0 ? 'default' : agentId);
|
|
982
|
+
// If accountId changed (e.g. was agentId, now should be 'default'), clean up old key
|
|
983
|
+
if (existingAccountId && existingAccountId !== accountId) delete accounts[existingAccountId];
|
|
984
|
+
accounts[accountId] = { botToken: token || accounts[accountId]?.botToken || accounts[existingAccountId]?.botToken || '' };
|
|
985
|
+
cfg.channels.telegram.accounts = accounts;
|
|
986
|
+
cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
|
|
987
|
+
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
|
|
988
|
+
} else if (channel === 'zalo-personal') {
|
|
989
|
+
ensureZaloUserChannel(cfg);
|
|
990
|
+
cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
|
|
991
|
+
} else {
|
|
992
|
+
ensureZaloApiChannel(cfg, token || cfg.channels?.zalo?.botToken || '');
|
|
993
|
+
cfg.bindings.push({ agentId, match: { channel: 'zalo' } });
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
agent.name = botName;
|
|
997
|
+
agent.role = botDesc;
|
|
998
|
+
validateOpenclawConfig(cfg);
|
|
999
|
+
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
1000
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1001
|
+
|
|
1002
|
+
// Synchronize the token to .env files for the primary bot to ensure Docker picks it up
|
|
1003
|
+
if (agentId === 'bot') {
|
|
1004
|
+
const envPath = join(projectDir, '.env');
|
|
1005
|
+
if (existsSync(envPath)) {
|
|
1006
|
+
let envContent = await fsp.readFile(envPath, 'utf8');
|
|
1007
|
+
if (channel === 'telegram') {
|
|
1008
|
+
if (envContent.includes('TELEGRAM_BOT_TOKEN=')) {
|
|
1009
|
+
envContent = envContent.replace(/TELEGRAM_BOT_TOKEN=.*/g, `TELEGRAM_BOT_TOKEN=${token}`);
|
|
1010
|
+
} else {
|
|
1011
|
+
envContent += `\nTELEGRAM_BOT_TOKEN=${token}\n`;
|
|
1012
|
+
}
|
|
1013
|
+
} else if (channel === 'zalo') {
|
|
1014
|
+
if (envContent.includes('ZALO_BOT_TOKEN=')) {
|
|
1015
|
+
envContent = envContent.replace(/ZALO_BOT_TOKEN=.*/g, `ZALO_BOT_TOKEN=${token}`);
|
|
1016
|
+
} else {
|
|
1017
|
+
envContent += `\nZALO_BOT_TOKEN=${token}\n`;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
await fsp.writeFile(envPath, envContent, 'utf8');
|
|
1021
|
+
|
|
1022
|
+
const dockerEnv = join(projectDir, 'docker', 'openclaw', '.env');
|
|
1023
|
+
if (existsSync(dockerEnv)) {
|
|
1024
|
+
await fsp.writeFile(dockerEnv, envContent, 'utf8');
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const files = buildWorkspaceFileMap({
|
|
1030
|
+
isVi: true,
|
|
1031
|
+
botName,
|
|
1032
|
+
botDesc,
|
|
1033
|
+
persona,
|
|
1034
|
+
emoji,
|
|
1035
|
+
userInfo,
|
|
1036
|
+
agentWorkspaceDir: workspaceDir,
|
|
1037
|
+
workspacePath: `.openclaw/${workspaceDir}`,
|
|
1038
|
+
hasZaloMod: channel === 'zalo-personal',
|
|
1039
|
+
});
|
|
1040
|
+
const wsRoot = join(projectDir, '.openclaw', workspaceDir);
|
|
1041
|
+
for (const [name, content] of Object.entries(files)) {
|
|
1042
|
+
await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
|
|
1043
|
+
await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
|
|
1044
|
+
}
|
|
1045
|
+
return { ok: true, agentId, channel, workspace: `.openclaw/${workspaceDir}` };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function openclawRuntimeEnv(projectDir) {
|
|
1049
|
+
return {
|
|
1050
|
+
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
1051
|
+
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
1052
|
+
DATA_DIR: join(projectDir, '.9router'),
|
|
1053
|
+
OPENCLAW_GATEWAY_PORT: String(state.gatewayPort || 18789),
|
|
1054
|
+
OPENCLAW_PORT: String(state.gatewayPort || 18789),
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function waitForDockerContainer(name, timeoutMs = 30000) {
|
|
1059
|
+
const started = Date.now();
|
|
1060
|
+
while (Date.now() - started < timeoutMs) {
|
|
1061
|
+
try {
|
|
1062
|
+
let status = '';
|
|
1063
|
+
await new Promise((resolve) => {
|
|
1064
|
+
const child = spawn('docker', ['inspect', '-f', '{{.State.Running}}', name], { shell: false, windowsHide: true });
|
|
1065
|
+
child.stdout.on('data', (d) => { status += String(d); });
|
|
1066
|
+
child.on('close', () => resolve());
|
|
1067
|
+
child.on('error', () => resolve());
|
|
1068
|
+
});
|
|
1069
|
+
if (status.trim() === 'true') return true;
|
|
1070
|
+
} catch {}
|
|
1071
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1072
|
+
}
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000) {
|
|
1077
|
+
const started = Date.now();
|
|
1078
|
+
// Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
|
|
1079
|
+
const healthScript = 'const http=require("http");const port=process.env.OPENCLAW_GATEWAY_PORT||process.env.OPENCLAW_PORT||18789;const r=http.get("http://127.0.0.1:"+port+"/health",{timeout:2000},(res)=>{let d="";res.on("data",c=>d+=c);res.on("end",()=>{try{const j=JSON.parse(d);process.stdout.write(j.ok?"READY":"WAIT")}catch{process.stdout.write("WAIT")}})});r.on("error",()=>process.stdout.write("WAIT"));r.on("timeout",()=>{r.destroy();process.stdout.write("WAIT")})';
|
|
1080
|
+
await waitForDockerContainer(botContainer, 15000);
|
|
1081
|
+
let ready = false;
|
|
1082
|
+
let attempts = 0;
|
|
1083
|
+
while (Date.now() - started < timeoutMs) {
|
|
1084
|
+
attempts++;
|
|
1085
|
+
try {
|
|
1086
|
+
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', healthScript], { cwd: projectDir, shell: false });
|
|
1087
|
+
const status = String(out.stdout || '').trim();
|
|
1088
|
+
if (status === 'READY') {
|
|
1089
|
+
const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
|
|
1090
|
+
const output = (pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '');
|
|
1091
|
+
if (output.toLowerCase().includes('zalouser') || output.toLowerCase().includes('zalo personal')) {
|
|
1092
|
+
ready = true;
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
if (attempts > 2) sendLog('[zalouser] Gateway healthy but zalouser not yet loaded (' + Math.round((Date.now() - started) / 1000) + 's)...');
|
|
1096
|
+
} else {
|
|
1097
|
+
if (attempts > 2 && attempts % 3 === 0) sendLog('[zalouser] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
|
|
1098
|
+
}
|
|
1099
|
+
} catch {}
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
1101
|
+
}
|
|
1102
|
+
if (!ready) {
|
|
1103
|
+
sendLog('[zalouser] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
|
|
1104
|
+
}
|
|
1105
|
+
return ready;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function startZaloUserLogin(projectDir, mode = state.mode) {
|
|
1109
|
+
const qrPaths = ['/tmp/openclaw/openclaw-zalouser-qr.png', '/tmp/openclaw/openclaw-zalouser-qr-default.png'];
|
|
1110
|
+
if (zaloLoginInFlight) {
|
|
1111
|
+
setImmediate(async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
const botContainer = getBotContainerName(projectDir);
|
|
1114
|
+
const js = `const fs=require('fs');const ps=${JSON.stringify(qrPaths)};for(const p of ps){try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));break;}}catch{}}`;
|
|
1115
|
+
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
|
|
1116
|
+
const b64 = extractCompletePngBase64(out.stdout);
|
|
1117
|
+
if (b64.length > 100) {
|
|
1118
|
+
sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
|
|
1119
|
+
sendLog('[zalouser] Found running login session. Displaying current QR code.');
|
|
1120
|
+
}
|
|
1121
|
+
} catch {}
|
|
1122
|
+
});
|
|
1123
|
+
return { message: 'Zalo login is already running. Keep this modal open...' };
|
|
1124
|
+
}
|
|
1125
|
+
zaloLoginInFlight = true;
|
|
1126
|
+
sendLog('[zalouser] Preparing login. QR will be generated for the UI modal.');
|
|
1127
|
+
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
1128
|
+
if ((mode === 'docker' || existsSync(composeFile)) && existsSync(composeFile)) {
|
|
1129
|
+
const botContainer = getBotContainerName(projectDir);
|
|
1130
|
+
// Verify if zalouser is properly registered in installs.json with channels array.
|
|
1131
|
+
// npm install --prefix misses this, which causes error:not configured.
|
|
1132
|
+
const checkRegistryScript = `
|
|
1133
|
+
const fs = require('fs');
|
|
1134
|
+
try {
|
|
1135
|
+
const dist = '/root/project/.openclaw/npm/node_modules/@openclaw/zalouser/dist/index.js';
|
|
1136
|
+
const inst = '/root/project/.openclaw/plugins/installs.json';
|
|
1137
|
+
if (!fs.existsSync(dist)) { console.log('MISSING'); process.exit(0); }
|
|
1138
|
+
if (!fs.existsSync(inst)) { console.log('MISSING_CHANNELS'); process.exit(0); }
|
|
1139
|
+
const j = JSON.parse(fs.readFileSync(inst, 'utf8'));
|
|
1140
|
+
const z = j.plugins.find(x => x.pluginId === 'zalouser');
|
|
1141
|
+
if (z && z.channels && z.channels.includes('zalouser')) {
|
|
1142
|
+
console.log('OK');
|
|
1143
|
+
} else {
|
|
1144
|
+
console.log('MISSING_CHANNELS');
|
|
1145
|
+
}
|
|
1146
|
+
} catch(e) { console.log('MISSING'); }
|
|
1147
|
+
`;
|
|
1148
|
+
const checkInstall = await runCapture('docker', ['exec', botContainer, 'node', '-e', checkRegistryScript], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'MISSING' }));
|
|
1149
|
+
const status = String(checkInstall.stdout || '').trim();
|
|
1150
|
+
if (status !== 'OK') {
|
|
1151
|
+
sendLog(status === 'MISSING' ? '[zalouser] Plugin not found — installing @openclaw/zalouser...' : '[zalouser] Plugin registry missing channels array — fixing install via CLI...');
|
|
1152
|
+
|
|
1153
|
+
const fixScript = `
|
|
1154
|
+
const fs=require('fs');
|
|
1155
|
+
const cp=require('child_process');
|
|
1156
|
+
const cfg='/root/project/.openclaw/openclaw.json';
|
|
1157
|
+
const bk='/root/project/.openclaw/openclaw.json.zalo-backup';
|
|
1158
|
+
try{if(fs.existsSync(cfg))fs.copyFileSync(cfg,bk);}catch(e){}
|
|
1159
|
+
// Detect gateway version and pin zalouser plugin to match, preventing createSetupTranslator mismatch
|
|
1160
|
+
let gatewayVer='';
|
|
1161
|
+
try{gatewayVer=cp.execSync('openclaw --version 2>/dev/null',{encoding:'utf8'}).trim().replace(/[^0-9.]/g,'');}catch(e){}
|
|
1162
|
+
const pluginSpec=gatewayVer ? '@openclaw/zalouser@'+gatewayVer : '@openclaw/zalouser';
|
|
1163
|
+
console.log('Installing plugin via CLI: '+pluginSpec+'...');
|
|
1164
|
+
try{cp.execSync('cd /root/project && openclaw plugins install '+pluginSpec+' --force',{stdio:'inherit'});}catch(e){
|
|
1165
|
+
// Fallback: try without version pin if exact version not found on registry
|
|
1166
|
+
if(gatewayVer){console.log('Pinned version failed, trying latest...');try{cp.execSync('cd /root/project && openclaw plugins install @openclaw/zalouser --force',{stdio:'inherit'});}catch(e2){console.error('Install failed');}}
|
|
1167
|
+
else{console.error('Install failed');}
|
|
1168
|
+
}
|
|
1169
|
+
try{
|
|
1170
|
+
if(fs.existsSync(bk)){
|
|
1171
|
+
const b=JSON.parse(fs.readFileSync(bk,'utf8'));
|
|
1172
|
+
const c=JSON.parse(fs.readFileSync(cfg,'utf8'));
|
|
1173
|
+
const keys=['agents','channels','bindings','gateway','models'];
|
|
1174
|
+
for(const k of keys){if(b[k])c[k]=b[k];}
|
|
1175
|
+
if(b.plugins){
|
|
1176
|
+
if(b.plugins.allow)c.plugins={...c.plugins,allow:b.plugins.allow};
|
|
1177
|
+
if(b.plugins.deny)c.plugins={...c.plugins,deny:b.plugins.deny};
|
|
1178
|
+
if(b.plugins.entries)c.plugins={...c.plugins,entries:b.plugins.entries};
|
|
1179
|
+
}
|
|
1180
|
+
if(!c.plugins)c.plugins={};if(!c.plugins.entries)c.plugins.entries={};
|
|
1181
|
+
if(!c.plugins.entries.zalouser)c.plugins.entries.zalouser={};
|
|
1182
|
+
c.plugins.entries.zalouser.enabled=true;
|
|
1183
|
+
fs.writeFileSync(cfg,JSON.stringify(c,null,2)+'\\n','utf8');
|
|
1184
|
+
fs.unlinkSync(bk);
|
|
1185
|
+
console.log('Config protected and restored.');
|
|
1186
|
+
}
|
|
1187
|
+
}catch(e){}
|
|
1188
|
+
try{
|
|
1189
|
+
console.log('Patching zalouser stability settings...');
|
|
1190
|
+
cp.execSync('ZALO_JS=$(find "/root/project/.openclaw" -path "*/zalouser/dist/zalo-js*.js" -type f 2>/dev/null | head -1); if [ -n "$ZALO_JS" ]; then sed -i "s/LISTENER_WATCHDOG_MAX_GAP_MS = 35e3/LISTENER_WATCHDOG_MAX_GAP_MS = 120e3/g" "$ZALO_JS"; echo "Patched watchdog gap to 120s"; fi', {shell:true,stdio:'inherit'});
|
|
1191
|
+
}catch(e){}
|
|
1192
|
+
try{
|
|
1193
|
+
const ep = '/root/project/docker/openclaw/entrypoint.sh';
|
|
1194
|
+
if (fs.existsSync(ep)) {
|
|
1195
|
+
let content = fs.readFileSync(ep, 'utf8');
|
|
1196
|
+
if (!content.includes('zalo-monitor')) {
|
|
1197
|
+
const monitor = "\\n# Zalo channel auto-restart monitor (background)\\n(\\n sleep 180\\n while true; do\\n sleep 60\\n STATUS=$(openclaw channels status 2>/dev/null | grep -i \\"Zalo Personal\\" || true)\\n if echo \\"$STATUS\\" | grep -qi \\"stopped\\"; then\\n echo \\"[zalo-monitor] Zalo channel stopped - restarting container in 5s\\"\\n sleep 5\\n kill 1 2>/dev/null || true\\n fi\\n done\\n) &\\n";
|
|
1198
|
+
// Insert before 'openclaw gateway run'
|
|
1199
|
+
const target = 'openclaw gateway run';
|
|
1200
|
+
if (content.includes(target)) {
|
|
1201
|
+
content = content.replace(target, monitor + target);
|
|
1202
|
+
fs.writeFileSync(ep, content, 'utf8');
|
|
1203
|
+
console.log('Added auto-restart monitor to entrypoint.sh');
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}catch(e){}
|
|
1208
|
+
`;
|
|
1209
|
+
const install = await runCapture('docker', ['exec', botContainer, 'node', '-e', fixScript], { cwd: projectDir, shell: false });
|
|
1210
|
+
for (const line of `${install.stdout}\n${install.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
|
|
1211
|
+
// Restart the gateway to load the new installs.json channels array so `openclaw channels login` works
|
|
1212
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
|
|
1213
|
+
// Wait for gateway to fully reload zalouser plugin after restart (~20s for plugin load + buffer)
|
|
1214
|
+
sendLog('[zalouser] Waiting for gateway to load zalouser plugin...');
|
|
1215
|
+
await waitForGatewayZaloReady(botContainer, projectDir);
|
|
1216
|
+
sendLog('[zalouser] Gateway ready with zalouser plugin.');
|
|
1217
|
+
} else {
|
|
1218
|
+
sendLog('[zalouser] Plugin already properly installed with channels array — skipping install.');
|
|
1219
|
+
// Even when plugin is installed, gateway may still be booting (e.g. after recreateDockerBot)
|
|
1220
|
+
await waitForGatewayZaloReady(botContainer, projectDir);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Clean old credentials & QR files inside container
|
|
1224
|
+
const credPath = '/root/project/.openclaw/credentials/zalouser/credentials.json';
|
|
1225
|
+
await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `rm -f ${credPath} ${qrPaths.join(' ')}`], { cwd: projectDir, shell: false });
|
|
1226
|
+
|
|
1227
|
+
sendLog('[zalouser] Generating Zalo QR. The image will appear automatically.');
|
|
1228
|
+
const loginCmd = 'cd /root/project && openclaw channels login --channel zalouser --verbose';
|
|
1229
|
+
|
|
1230
|
+
// Retry-based login: the zalouser plugin may need time to connect to Zalo servers.
|
|
1231
|
+
// The CLI often exits with "Still preparing QR" on the first attempt.
|
|
1232
|
+
const MAX_LOGIN_ATTEMPTS = 4;
|
|
1233
|
+
const RETRY_DELAYS = [0, 8000, 15000, 20000];
|
|
1234
|
+
let restartAfterLogin = false;
|
|
1235
|
+
let loginAttempt = 0;
|
|
1236
|
+
let sent = false;
|
|
1237
|
+
|
|
1238
|
+
// Start QR file polling in parallel (runs across all retry attempts)
|
|
1239
|
+
let tries = 0;
|
|
1240
|
+
const poll = setInterval(async () => {
|
|
1241
|
+
if (sent || tries++ > 120) {
|
|
1242
|
+
clearInterval(poll);
|
|
1243
|
+
if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login or recreate Zalo User bot.');
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const js = `const fs=require('fs');const ps=${JSON.stringify(qrPaths)};for(const p of ps){try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));break;}}catch{}}`;
|
|
1247
|
+
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
|
|
1248
|
+
const b64 = extractCompletePngBase64(out.stdout);
|
|
1249
|
+
if (b64.length > 100) {
|
|
1250
|
+
sent = true;
|
|
1251
|
+
clearInterval(poll);
|
|
1252
|
+
sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
|
|
1253
|
+
sendLog('[zalouser] Scan this QR with the Zalo app.');
|
|
1254
|
+
}
|
|
1255
|
+
}, 1000);
|
|
1256
|
+
|
|
1257
|
+
const runLoginAttempt = () => {
|
|
1258
|
+
loginAttempt++;
|
|
1259
|
+
if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
|
|
1260
|
+
const child = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
|
|
1261
|
+
const handleLoginLine = (line) => {
|
|
1262
|
+
sendLog(line);
|
|
1263
|
+
if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
|
|
1264
|
+
};
|
|
1265
|
+
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
1266
|
+
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
1267
|
+
child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}`));
|
|
1268
|
+
child.on('close', async (code) => {
|
|
1269
|
+
sendLog(`[zalouser] Login process exited ${code}`);
|
|
1270
|
+
if (code === 0 || restartAfterLogin || sent) {
|
|
1271
|
+
// Success or QR already found by poll
|
|
1272
|
+
if (restartAfterLogin) {
|
|
1273
|
+
sendLog(`[zalouser] Login saved. Restarting ${botContainer} container so Zalo User can receive messages...`);
|
|
1274
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalouser] Container restart failed: ${err.message}`));
|
|
1275
|
+
sendLog(`[zalouser] ${botContainer} restarted. Try sending a Zalo message now.`);
|
|
1276
|
+
}
|
|
1277
|
+
zaloLoginInFlight = false;
|
|
1278
|
+
} else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
|
|
1279
|
+
// Failed with "Still preparing QR" — retry after delay
|
|
1280
|
+
const delay = RETRY_DELAYS[loginAttempt] || 10000;
|
|
1281
|
+
sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
|
|
1282
|
+
setTimeout(runLoginAttempt, delay);
|
|
1283
|
+
} else {
|
|
1284
|
+
sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
|
|
1285
|
+
zaloLoginInFlight = false;
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
runLoginAttempt();
|
|
1291
|
+
return { message: 'Generating Zalo QR. The image will appear automatically.' };
|
|
1292
|
+
}
|
|
1293
|
+
zaloLoginInFlight = false;
|
|
1294
|
+
return { message: 'Native Zalo login UI not implemented yet in local setup.' };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function getBotServiceName(projectDir) {
|
|
1298
|
+
const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
|
|
1299
|
+
if (!existsSync(composeFile)) return 'ai-bot';
|
|
1300
|
+
try {
|
|
1301
|
+
const content = fs.readFileSync(composeFile, 'utf8');
|
|
1302
|
+
const servicesMatch = content.match(/services:\s*\n([\s\S]+?)(?=\n\S|\n$)/);
|
|
1303
|
+
if (servicesMatch) {
|
|
1304
|
+
const servicesText = servicesMatch[1];
|
|
1305
|
+
const keys = Array.from(servicesText.matchAll(/^\s{2}([a-zA-Z0-9_-]+):/gm)).map(m => m[1]);
|
|
1306
|
+
const botService = keys.find(k => k !== '9router');
|
|
1307
|
+
if (botService) return botService;
|
|
1308
|
+
}
|
|
1309
|
+
} catch (e) {}
|
|
1310
|
+
return 'ai-bot';
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function getBotContainerName(projectDir) {
|
|
1314
|
+
const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
|
|
1315
|
+
if (!existsSync(composeFile)) return 'openclaw-bot';
|
|
1316
|
+
try {
|
|
1317
|
+
const content = fs.readFileSync(composeFile, 'utf8');
|
|
1318
|
+
const containerMatch = content.match(/container_name:\s*([a-zA-Z0-9_-]+)/);
|
|
1319
|
+
if (containerMatch) return containerMatch[1];
|
|
1320
|
+
} catch (e) {}
|
|
1321
|
+
return 'openclaw-bot';
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function recreateDockerBot(projectDir) {
|
|
1325
|
+
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
1326
|
+
if (!existsSync(composeFile)) return false;
|
|
1327
|
+
const depDir = join(projectDir, '.openclaw', 'plugin-runtime-deps');
|
|
1328
|
+
await fsp.mkdir(depDir, { recursive: true }).catch(() => {});
|
|
1329
|
+
const serviceName = getBotServiceName(projectDir);
|
|
1330
|
+
const containerName = getBotContainerName(projectDir);
|
|
1331
|
+
sendLog(`[docker] Recreating ${serviceName} to reload openclaw.json/.env...`);
|
|
1332
|
+
await run('docker', ['compose', '-f', composeFile, 'up', '-d', '--force-recreate', serviceName], { cwd: projectDir });
|
|
1333
|
+
await waitForDockerContainer(containerName);
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function updateRuntime(target, projectDir) {
|
|
1338
|
+
const isRouter = target === '9router';
|
|
1339
|
+
const spec = isRouter ? NINE_ROUTER_NPM_SPEC : OPENCLAW_NPM_SPEC;
|
|
1340
|
+
if (state.mode === 'docker' && projectDir) {
|
|
1341
|
+
const dockerDir = join(projectDir, 'docker', 'openclaw');
|
|
1342
|
+
if (isRouter) {
|
|
1343
|
+
await run('docker', ['compose', 'pull', '9router'], { cwd: dockerDir }).catch(() => {});
|
|
1344
|
+
await run('docker', ['compose', 'up', '-d', '--force-recreate', '9router'], { cwd: dockerDir });
|
|
1345
|
+
} else {
|
|
1346
|
+
const serviceName = getBotServiceName(projectDir);
|
|
1347
|
+
const containerName = getBotContainerName(projectDir);
|
|
1348
|
+
await run('docker', ['compose', 'build', '--no-cache', serviceName], { cwd: dockerDir });
|
|
1349
|
+
await run('docker', ['compose', 'up', '-d', '--force-recreate', serviceName], { cwd: dockerDir });
|
|
1350
|
+
}
|
|
1351
|
+
await syncRuntimeState(projectDir).catch(() => {});
|
|
1352
|
+
return { ok: true, target, spec, mode: 'docker' };
|
|
1353
|
+
}
|
|
1354
|
+
await run('npm', ['install', '-g', spec]);
|
|
1355
|
+
if (isRouter) {
|
|
1356
|
+
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir }).catch(() => {});
|
|
1357
|
+
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
1358
|
+
} else {
|
|
1359
|
+
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
1360
|
+
}
|
|
1361
|
+
await syncRuntimeState(projectDir).catch(() => {});
|
|
1362
|
+
return { ok: true, target, spec, mode: 'native' };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
1366
|
+
const containerName = getBotContainerName(projectDir);
|
|
1367
|
+
sendLog(`[docker] Restarting ${containerName} container...`);
|
|
1368
|
+
await run('docker', ['restart', containerName], { shell: false });
|
|
1369
|
+
await waitForDockerContainer(containerName);
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function readJson(req) {
|
|
1374
|
+
const chunks = [];
|
|
1375
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1376
|
+
if (!chunks.length) return {};
|
|
1377
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function json(res, data, status = 200) {
|
|
1381
|
+
const body = JSON.stringify(data, null, 2);
|
|
1382
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1383
|
+
res.end(body);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 18789, routerPort = 20128 }) {
|
|
1387
|
+
await fsp.mkdir(projectDir, { recursive: true });
|
|
1388
|
+
const openclawHome = join(projectDir, '.openclaw');
|
|
1389
|
+
await fsp.mkdir(openclawHome, { recursive: true });
|
|
1390
|
+
await fsp.mkdir(join(openclawHome, 'plugin-runtime-deps'), { recursive: true });
|
|
1391
|
+
|
|
1392
|
+
const selectedSkills = [];
|
|
1393
|
+
const botName = 'OpenClaw Bot';
|
|
1394
|
+
const agentMetas = [{ agentId: 'bot', name: botName, description: 'Personal OpenClaw assistant' }];
|
|
1395
|
+
const common = { botName, channelKey: 'telegram', providerKey: '9router', model: DEFAULT_MODEL, deployMode: mode, osChoice, selectedSkills, skills: dataExport.SKILLS || [], agentMetas, gatewayPort, routerPort };
|
|
1396
|
+
const cfg = buildOpenclawJson(common);
|
|
1397
|
+
const env = buildEnvFileContent({ ...common, apiKey: '', botToken: '' });
|
|
1398
|
+
const approvals = buildExecApprovalsJson({ agentMetas });
|
|
1399
|
+
|
|
1400
|
+
await fsp.writeFile(join(openclawHome, 'openclaw.json'), JSON.stringify(cfg, null, 2), 'utf8');
|
|
1401
|
+
await fsp.writeFile(join(projectDir, '.env'), env, 'utf8');
|
|
1402
|
+
await fsp.writeFile(join(openclawHome, 'exec-approvals.json'), JSON.stringify(approvals, null, 2), 'utf8');
|
|
1403
|
+
|
|
1404
|
+
const workspaceDir = 'workspace-bot';
|
|
1405
|
+
const workspace = buildWorkspaceFileMap({
|
|
1406
|
+
isVi: true,
|
|
1407
|
+
botName,
|
|
1408
|
+
channelKey: 'telegram',
|
|
1409
|
+
providerKey: '9router',
|
|
1410
|
+
selectedSkills,
|
|
1411
|
+
skillsCatalog: dataExport.SKILLS || [],
|
|
1412
|
+
agentMetas,
|
|
1413
|
+
deployMode: mode,
|
|
1414
|
+
osChoice,
|
|
1415
|
+
agentWorkspaceDir: workspaceDir,
|
|
1416
|
+
workspacePath: `.openclaw/${workspaceDir}`,
|
|
1417
|
+
});
|
|
1418
|
+
const wsRoot = join(openclawHome, workspaceDir);
|
|
1419
|
+
for (const [name, content] of Object.entries(workspace)) {
|
|
1420
|
+
await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
|
|
1421
|
+
await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (mode === 'docker') {
|
|
1425
|
+
const projectName = slugify(basename(projectDir)) || 'bot';
|
|
1426
|
+
const docker = buildDockerArtifacts({
|
|
1427
|
+
is9Router: true,
|
|
1428
|
+
osChoice,
|
|
1429
|
+
openClawNpmSpec: OPENCLAW_NPM_SPEC,
|
|
1430
|
+
allSkills: [],
|
|
1431
|
+
dockerfilePlugins: [],
|
|
1432
|
+
gatewayPort,
|
|
1433
|
+
routerPort,
|
|
1434
|
+
singleComposeName: `oc-${projectName}`,
|
|
1435
|
+
singleAppContainerName: `openclaw-${projectName}`,
|
|
1436
|
+
singleRouterContainerName: `9router-${projectName}`,
|
|
1437
|
+
});
|
|
1438
|
+
const dockerDir = join(projectDir, 'docker', 'openclaw');
|
|
1439
|
+
await fsp.mkdir(dockerDir, { recursive: true });
|
|
1440
|
+
const compose = String(docker.compose || '')
|
|
1441
|
+
.replace(/env_file:\s*\n\s*-\s*\.env/g, 'env_file:\n - ../../.env')
|
|
1442
|
+
.replace(/env_file:\s*\.env/g, 'env_file: ../../.env');
|
|
1443
|
+
sendLog(`[writeCoreProject] Writing docker files to ${dockerDir} (compose ${compose.length} bytes, routerPort=${routerPort})`);
|
|
1444
|
+
await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
|
|
1445
|
+
await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), compose, 'utf8');
|
|
1446
|
+
await fsp.writeFile(join(dockerDir, 'entrypoint.sh'), docker.entrypointScript || docker.entrypoint || '', 'utf8');
|
|
1447
|
+
// Write 9router helper scripts as separate files (mounted as volumes)
|
|
1448
|
+
if (docker.syncScript) await fsp.writeFile(join(dockerDir, 'sync.js'), docker.syncScript, 'utf8');
|
|
1449
|
+
if (docker.patchScript) await fsp.writeFile(join(dockerDir, 'patch-9router.js'), docker.patchScript, 'utf8');
|
|
1450
|
+
// docker-compose.yml uses env_file: .env relative to docker/openclaw.
|
|
1451
|
+
await fsp.writeFile(join(dockerDir, '.env'), env, 'utf8');
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, routerPort = 20128 }) {
|
|
1456
|
+
state.installing = true;
|
|
1457
|
+
state.installed = false;
|
|
1458
|
+
state.lastError = null;
|
|
1459
|
+
state.projectDir = projectDir;
|
|
1460
|
+
state.mode = mode;
|
|
1461
|
+
state.os = osChoice;
|
|
1462
|
+
state.startedAt = new Date().toISOString();
|
|
1463
|
+
try {
|
|
1464
|
+
sendLog('OpenClaw local installer started');
|
|
1465
|
+
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
1466
|
+
await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort });
|
|
1467
|
+
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
1468
|
+
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
1469
|
+
if (mode === 'docker') {
|
|
1470
|
+
const dockerDir = join(projectDir, 'docker', 'openclaw');
|
|
1471
|
+
const rootEnvPath = join(projectDir, '.env');
|
|
1472
|
+
const dockerEnvPath = join(dockerDir, '.env');
|
|
1473
|
+
await fsp.mkdir(dockerDir, { recursive: true });
|
|
1474
|
+
const envContent = existsSync(rootEnvPath)
|
|
1475
|
+
? await fsp.readFile(rootEnvPath, 'utf8')
|
|
1476
|
+
: buildEnvFileContent({ botName: 'OpenClaw Bot', channelKey: 'telegram', providerKey: '9router', deployMode: mode, osChoice, selectedSkills: [], skills: dataExport.SKILLS || [], agentMetas: [{ agentId: 'bot', name: 'OpenClaw Bot', description: 'Personal OpenClaw assistant' }], apiKey: '', botToken: '' });
|
|
1477
|
+
await fsp.writeFile(dockerEnvPath, envContent, 'utf8');
|
|
1478
|
+
sendLog(`Docker env ready: ${dockerEnvPath}`);
|
|
1479
|
+
await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
|
|
1480
|
+
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
1481
|
+
await recreateDockerBot(projectDir).catch(() => {});
|
|
1482
|
+
} else {
|
|
1483
|
+
const runtimeEnv = {
|
|
1484
|
+
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
1485
|
+
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
1486
|
+
DATA_DIR: join(projectDir, '.9router'),
|
|
1487
|
+
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
1488
|
+
OPENCLAW_PORT: String(gatewayPort),
|
|
1489
|
+
};
|
|
1490
|
+
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
|
|
1491
|
+
startDetached('9router', ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], { cwd: projectDir, env: runtimeEnv });
|
|
1492
|
+
state.botPid = startDetached('openclaw', ['gateway', 'run'], { cwd: projectDir, env: runtimeEnv });
|
|
1493
|
+
sendLog(`Native gateway started in background (pid=${state.botPid || 'unknown'})`);
|
|
1494
|
+
}
|
|
1495
|
+
state.installed = true;
|
|
1496
|
+
sendLog('✅ Install completed');
|
|
1497
|
+
sendLog(`Gateway: ${state.gatewayUrl}`);
|
|
1498
|
+
sendLog(`9Router: http://127.0.0.1:${state.routerPort || routerPort}`);
|
|
1499
|
+
} catch (err) {
|
|
1500
|
+
state.lastError = err.message;
|
|
1501
|
+
sendLog(`ERROR: ${err.message}`);
|
|
1502
|
+
throw err;
|
|
1503
|
+
} finally {
|
|
1504
|
+
state.installing = false;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function listMarkdownFiles(projectDir, agentId = '') {
|
|
1509
|
+
const out = [];
|
|
1510
|
+
const home = join(projectDir, '.openclaw');
|
|
1511
|
+
const cfgPath = join(home, 'openclaw.json');
|
|
1512
|
+
const cfg = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8'))) : null;
|
|
1513
|
+
const agent = agentId && cfg ? cfg.agents.list.find((a) => a.id === agentId) : null;
|
|
1514
|
+
const workspaceDirs = agent
|
|
1515
|
+
? [workspaceRelForAgent(agent, cfg, projectDir)]
|
|
1516
|
+
: [];
|
|
1517
|
+
if (agentId && !agent) throw httpError(404, 'Bot not found');
|
|
1518
|
+
const textExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
|
|
1519
|
+
async function walk(absDir, relDir = '', depth = 0) {
|
|
1520
|
+
if (depth > 8) return;
|
|
1521
|
+
const entries = await fsp.readdir(absDir, { withFileTypes: true }).catch(() => []);
|
|
1522
|
+
for (const e of entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))) {
|
|
1523
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'plugin-runtime-deps') continue;
|
|
1524
|
+
const abs = join(absDir, e.name);
|
|
1525
|
+
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
1526
|
+
const relProject = rel.replace(/\\/g, '/');
|
|
1527
|
+
if (e.isDirectory()) {
|
|
1528
|
+
out.push({ name: relProject, path: relProject, type: 'dir' });
|
|
1529
|
+
await walk(abs, relProject, depth + 1);
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
const st = await fsp.stat(abs).catch(() => null);
|
|
1533
|
+
const ext = extname(e.name).toLowerCase() || (e.name === '.env' ? '.env' : '');
|
|
1534
|
+
const isText = !!st && st.size <= 1024 * 1024 && (textExt.has(ext) || !ext);
|
|
1535
|
+
out.push({ name: relProject, path: relProject, type: 'file', content: isText ? await fsp.readFile(abs, 'utf8').catch(() => '') : '', editable: isText });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (existsSync(home)) {
|
|
1539
|
+
const dirs = workspaceDirs.length
|
|
1540
|
+
? workspaceDirs
|
|
1541
|
+
: (await fsp.readdir(home, { withFileTypes: true }).catch(() => [])).filter((d) => d.isDirectory() && (d.name === 'workspace' || d.name.startsWith('workspace-'))).map((d) => d.name);
|
|
1542
|
+
for (const dir of dirs) {
|
|
1543
|
+
const abs = join(home, dir);
|
|
1544
|
+
if (existsSync(abs)) await walk(abs, `.openclaw/${dir}`);
|
|
1545
|
+
}
|
|
1546
|
+
// Also include project-level config files from .openclaw root
|
|
1547
|
+
const rootEntries = await fsp.readdir(home, { withFileTypes: true }).catch(() => []);
|
|
1548
|
+
const extraDirs = new Set(['extensions', 'plugins', 'agents', 'credentials']);
|
|
1549
|
+
for (const e of rootEntries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))) {
|
|
1550
|
+
if (e.isDirectory()) {
|
|
1551
|
+
// Walk extensions/ and agents/ directories so plugins show up in the tree
|
|
1552
|
+
if (extraDirs.has(e.name)) {
|
|
1553
|
+
const abs = join(home, e.name);
|
|
1554
|
+
await walk(abs, `.openclaw/${e.name}`);
|
|
1555
|
+
}
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
const abs = join(home, e.name);
|
|
1559
|
+
const rel = `.openclaw/${e.name}`;
|
|
1560
|
+
const ext = extname(e.name).toLowerCase();
|
|
1561
|
+
const isText = textExt.has(ext);
|
|
1562
|
+
if (!isText) continue;
|
|
1563
|
+
const st = await fsp.stat(abs).catch(() => null);
|
|
1564
|
+
if (!st || st.size > 1024 * 1024) continue;
|
|
1565
|
+
out.push({ name: rel, path: rel, type: 'file', content: await fsp.readFile(abs, 'utf8').catch(() => ''), editable: true });
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return out;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async function saveState(rootProjectDir) {
|
|
1572
|
+
const file = join(rootProjectDir, STATE_FILE);
|
|
1573
|
+
await fsp.writeFile(file, JSON.stringify({
|
|
1574
|
+
projectDir: state.projectDir,
|
|
1575
|
+
mode: state.mode,
|
|
1576
|
+
os: state.os,
|
|
1577
|
+
installed: state.installed,
|
|
1578
|
+
gatewayUrl: state.gatewayUrl,
|
|
1579
|
+
gatewayPort: state.gatewayPort,
|
|
1580
|
+
routerUrl: state.routerUrl,
|
|
1581
|
+
routerPort: state.routerPort,
|
|
1582
|
+
}, null, 2), 'utf8').catch(() => {});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async function loadSavedState(rootProjectDir) {
|
|
1586
|
+
const file = join(rootProjectDir, STATE_FILE);
|
|
1587
|
+
if (!existsSync(file)) return;
|
|
1588
|
+
const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
1589
|
+
if (saved?.projectDir && existsSync(join(saved.projectDir, '.openclaw', 'openclaw.json'))) {
|
|
1590
|
+
Object.assign(state, saved, { installed: !!saved.installed });
|
|
1591
|
+
await syncRuntimeState(state.projectDir);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async function findLatestProject(rootProjectDir) {
|
|
1596
|
+
const roots = [
|
|
1597
|
+
process.env.OPENCLAW_PROJECT_DIR,
|
|
1598
|
+
process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '',
|
|
1599
|
+
rootProjectDir,
|
|
1600
|
+
join(rootProjectDir, DEFAULT_PROJECT_NAME),
|
|
1601
|
+
dirname(rootProjectDir),
|
|
1602
|
+
os.homedir(),
|
|
1603
|
+
'D:\\tmp',
|
|
1604
|
+
];
|
|
1605
|
+
for (const drive of ['D:\\', 'E:\\']) {
|
|
1606
|
+
const entries = await fsp.readdir(drive, { withFileTypes: true }).catch(() => []);
|
|
1607
|
+
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('$')) roots.push(join(drive, e.name));
|
|
1608
|
+
}
|
|
1609
|
+
const candidates = [];
|
|
1610
|
+
async function walk(dir, depth = 0) {
|
|
1611
|
+
if (!dir || depth > 2 || !existsSync(dir)) return;
|
|
1612
|
+
if (existsSync(join(dir, '.openclaw', 'openclaw.json'))) {
|
|
1613
|
+
const st = await fsp.stat(join(dir, '.openclaw', 'openclaw.json')).catch(() => null);
|
|
1614
|
+
if (st) candidates.push({ dir, mtimeMs: st.mtimeMs });
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1618
|
+
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') await walk(join(dir, e.name), depth + 1);
|
|
1619
|
+
}
|
|
1620
|
+
for (const r of roots) await walk(r);
|
|
1621
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1622
|
+
return candidates[0]?.dir || null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
async function discoverProjects(rootProjectDir) {
|
|
1626
|
+
const roots = [
|
|
1627
|
+
process.env.OPENCLAW_PROJECT_DIR,
|
|
1628
|
+
rootProjectDir,
|
|
1629
|
+
dirname(rootProjectDir),
|
|
1630
|
+
process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '',
|
|
1631
|
+
'D:\\tmp',
|
|
1632
|
+
'D:\\',
|
|
1633
|
+
'E:\\',
|
|
1634
|
+
];
|
|
1635
|
+
const seen = new Set();
|
|
1636
|
+
const hits = [];
|
|
1637
|
+
async function walk(dir, depth = 0) {
|
|
1638
|
+
if (!dir || depth > 2 || !existsSync(dir)) return;
|
|
1639
|
+
const full = resolve(dir);
|
|
1640
|
+
if (full === resolve(os.homedir())) return;
|
|
1641
|
+
if (seen.has(full)) return;
|
|
1642
|
+
seen.add(full);
|
|
1643
|
+
const cfgPath = join(full, '.openclaw', 'openclaw.json');
|
|
1644
|
+
if (existsSync(cfgPath)) {
|
|
1645
|
+
const st = await fsp.stat(cfgPath).catch(() => null);
|
|
1646
|
+
const runtime = await detectRuntime(full).catch(() => ({ mode: 'unknown', gatewayPort: 0, routerPort: 0, syncSource: 'config' }));
|
|
1647
|
+
const bots = await listConfiguredBots(full).catch(() => []);
|
|
1648
|
+
const uniqueBotCount = new Set(bots.map((b) => b.id)).size;
|
|
1649
|
+
const hasDocker = existsSync(join(full, 'docker', 'openclaw', 'docker-compose.yml'));
|
|
1650
|
+
const isLikelyProject = uniqueBotCount > 0 || hasDocker || existsSync(join(full, '.env')) || existsSync(join(full, 'package.json'));
|
|
1651
|
+
if (!isLikelyProject) return;
|
|
1652
|
+
hits.push({
|
|
1653
|
+
projectDir: full,
|
|
1654
|
+
os: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux',
|
|
1655
|
+
mode: runtime.mode || 'unknown',
|
|
1656
|
+
gatewayPort: runtime.gatewayPort || 0,
|
|
1657
|
+
routerPort: runtime.routerPort || 0,
|
|
1658
|
+
syncSource: runtime.syncSource || 'config',
|
|
1659
|
+
botCount: uniqueBotCount,
|
|
1660
|
+
hasDocker,
|
|
1661
|
+
updatedAt: st?.mtimeMs || 0,
|
|
1662
|
+
});
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const entries = await fsp.readdir(full, { withFileTypes: true }).catch(() => []);
|
|
1666
|
+
for (const e of entries) {
|
|
1667
|
+
if (!e.isDirectory()) continue;
|
|
1668
|
+
if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
|
|
1669
|
+
await walk(join(full, e.name), depth + 1);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
for (const root of roots) await walk(root);
|
|
1673
|
+
hits.sort((a, b) =>
|
|
1674
|
+
(b.botCount - a.botCount) ||
|
|
1675
|
+
(Number(b.hasDocker) - Number(a.hasDocker)) ||
|
|
1676
|
+
(b.updatedAt - a.updatedAt)
|
|
1677
|
+
);
|
|
1678
|
+
return hits.slice(0, 20);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function resolveProjectDir(rootProjectDir, body = {}) {
|
|
1682
|
+
if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))) {
|
|
1683
|
+
state.projectDir = resolve(String(body.projectDir));
|
|
1684
|
+
await syncRuntimeState(state.projectDir);
|
|
1685
|
+
return state.projectDir;
|
|
1686
|
+
}
|
|
1687
|
+
const envProjectDir = process.env.OPENCLAW_PROJECT_DIR || (process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '');
|
|
1688
|
+
if (envProjectDir && existsSync(join(resolve(String(envProjectDir)), '.openclaw', 'openclaw.json'))) {
|
|
1689
|
+
state.projectDir = resolve(String(envProjectDir));
|
|
1690
|
+
await syncRuntimeState(state.projectDir);
|
|
1691
|
+
return state.projectDir;
|
|
1692
|
+
}
|
|
1693
|
+
if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
|
|
1694
|
+
await syncRuntimeState(state.projectDir);
|
|
1695
|
+
return state.projectDir;
|
|
1696
|
+
}
|
|
1697
|
+
await loadSavedState(rootProjectDir);
|
|
1698
|
+
if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
|
|
1699
|
+
await syncRuntimeState(state.projectDir);
|
|
1700
|
+
return state.projectDir;
|
|
1701
|
+
}
|
|
1702
|
+
const found = await findLatestProject(rootProjectDir);
|
|
1703
|
+
if (found) {
|
|
1704
|
+
await syncRuntimeState(found);
|
|
1705
|
+
await saveState(rootProjectDir);
|
|
1706
|
+
}
|
|
1707
|
+
return state.projectDir;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async function connectExistingProject(projectDir, rootProjectDir) {
|
|
1711
|
+
const resolved = resolve(String(projectDir || ''));
|
|
1712
|
+
if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
|
|
1713
|
+
await syncRuntimeState(resolved);
|
|
1714
|
+
await saveState(rootProjectDir);
|
|
1715
|
+
const bots = await listConfiguredBots(resolved).catch(() => []);
|
|
1716
|
+
return {
|
|
1717
|
+
ok: true,
|
|
1718
|
+
projectDir: resolved,
|
|
1719
|
+
mode: state.mode,
|
|
1720
|
+
syncSource: state.syncSource,
|
|
1721
|
+
gatewayUrl: state.gatewayUrl,
|
|
1722
|
+
gatewayPort: state.gatewayPort,
|
|
1723
|
+
routerUrl: state.routerUrl,
|
|
1724
|
+
routerPort: state.routerPort,
|
|
1725
|
+
bots,
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async function connectPickedProject(projectName, rootProjectDir) {
|
|
1730
|
+
const name = String(projectName || '').trim();
|
|
1731
|
+
if (!name) throw httpError(400, 'Missing project name');
|
|
1732
|
+
const projects = await discoverProjects(rootProjectDir).catch(() => []);
|
|
1733
|
+
const matches = projects.filter((p) => basename(resolve(p.projectDir)) === name);
|
|
1734
|
+
if (matches.length === 1) return connectExistingProject(matches[0].projectDir, rootProjectDir);
|
|
1735
|
+
if (matches.length > 1) {
|
|
1736
|
+
throw httpError(409, `Multiple projects named "${name}" found; use a detected project card or type the path manually`);
|
|
1737
|
+
}
|
|
1738
|
+
throw httpError(404, `No detected project named "${name}"`);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
async function deleteProjectFolder(projectDir, rootProjectDir) {
|
|
1742
|
+
const resolved = resolve(String(projectDir || ''));
|
|
1743
|
+
const home = resolve(os.homedir());
|
|
1744
|
+
if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
|
|
1745
|
+
if (resolved === home || /^[A-Za-z]:\\?$/.test(resolved)) throw httpError(403, 'Refusing to delete home/root folder');
|
|
1746
|
+
const projects = await discoverProjects(rootProjectDir).catch(() => []);
|
|
1747
|
+
const meta = projects.find((p) => resolve(p.projectDir) === resolved);
|
|
1748
|
+
if (!meta || !meta.botCount) throw httpError(403, 'Refusing to delete a folder that is not a detected bot project');
|
|
1749
|
+
// Stop and remove Docker containers first to release host folder locks
|
|
1750
|
+
const dockerComposeDir = join(resolved, 'docker', 'openclaw');
|
|
1751
|
+
if (existsSync(join(dockerComposeDir, 'docker-compose.yml'))) {
|
|
1752
|
+
sendLog(`[docker] Stopping and removing containers and volumes for ${resolved} to release file locks...`);
|
|
1753
|
+
await run('docker', ['compose', 'down', '-v'], { cwd: dockerComposeDir }).catch((err) => {
|
|
1754
|
+
sendLog(`[docker] Warning: Failed to stop compose containers: ${err.message}`);
|
|
1755
|
+
});
|
|
1756
|
+
// Sleep 2.5 seconds to let Windows file system release overlays/locks
|
|
1757
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
try {
|
|
1761
|
+
await fsp.rm(resolved, { recursive: true, force: true });
|
|
1762
|
+
} catch (err) {
|
|
1763
|
+
throw httpError(500, `Không thể xóa thư mục ${resolved}. Lý do: ${err.message}. (Gợi ý: Thư mục này có thể đang bị khóa bởi tiến trình khác, ví dụ như VS Code, Command Prompt/PowerShell đang cd vào thư mục, hoặc Docker chưa kịp tháo dỡ hoàn toàn. Vui lòng đóng tất cả các file/đóng terminal đang mở tại thư mục này, tắt Docker Desktop nếu cần, và bấm Xóa lại nhé!)`);
|
|
1764
|
+
}
|
|
1765
|
+
if (state.projectDir && resolve(state.projectDir) === resolved) {
|
|
1766
|
+
state.projectDir = null;
|
|
1767
|
+
state.installed = false;
|
|
1768
|
+
}
|
|
1769
|
+
await saveState(rootProjectDir);
|
|
1770
|
+
return { ok: true, projectDir: resolved };
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
async function pickProjectFolder() {
|
|
1774
|
+
if (process.platform !== 'win32') throw httpError(501, 'Folder picker currently supported on Windows only');
|
|
1775
|
+
const script = `
|
|
1776
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
1777
|
+
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
1778
|
+
$dlg.Description = "Select an OpenClaw project folder"
|
|
1779
|
+
$dlg.ShowNewFolderButton = $true
|
|
1780
|
+
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
|
1781
|
+
Write-Output $dlg.SelectedPath
|
|
1782
|
+
}
|
|
1783
|
+
`;
|
|
1784
|
+
const out = await runCapture('powershell', ['-NoProfile', '-STA', '-Command', script], { shell: false, windowsHide: false, timeout: 120000 });
|
|
1785
|
+
const projectDir = String(out.stdout || '').trim();
|
|
1786
|
+
if (!projectDir) throw httpError(400, 'No folder selected');
|
|
1787
|
+
return { ok: true, projectDir };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function upsertManagedBlock(text = '', key = '', content = '') {
|
|
1791
|
+
const start = `<!-- OPENCLAW:${key}:START -->`;
|
|
1792
|
+
const end = `<!-- OPENCLAW:${key}:END -->`;
|
|
1793
|
+
const block = `${start}\n${content}\n${end}`;
|
|
1794
|
+
const re = new RegExp(`${start}[\\s\\S]*?${end}`, 'm');
|
|
1795
|
+
if (re.test(text)) return text.replace(re, block);
|
|
1796
|
+
return `${String(text || '').trimEnd()}\n\n${block}\n`;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function removeManagedBlock(text = '', key = '') {
|
|
1800
|
+
const start = `<!-- OPENCLAW:${key}:START -->`;
|
|
1801
|
+
const end = `<!-- OPENCLAW:${key}:END -->`;
|
|
1802
|
+
const re = new RegExp(`\\n?${start}[\\s\\S]*?${end}\\n?`, 'm');
|
|
1803
|
+
return String(text || '').replace(re, '\n').trimEnd() + '\n';
|
|
1804
|
+
}
|
|
1805
|
+
async function readWorkspaceText(projectDir, agent, name) {
|
|
1806
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
1807
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
|
|
1808
|
+
const rel = workspaceRelForAgent(agent, cfg, projectDir);
|
|
1809
|
+
const file = join(projectDir, '.openclaw', rel, name);
|
|
1810
|
+
return { file, content: existsSync(file) ? await fsp.readFile(file, 'utf8').catch(() => '') : '' };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
|
|
1814
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
1815
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
1816
|
+
const agent = cfg.agents.list.find((a) => a.id === agentId) || cfg.agents.list[0];
|
|
1817
|
+
if (!agent) throw httpError(404, 'Bot not found');
|
|
1818
|
+
|
|
1819
|
+
const k = `${kind}:${id}`;
|
|
1820
|
+
|
|
1821
|
+
if (kind === 'skill' && id === 'browser') {
|
|
1822
|
+
if (enabled) {
|
|
1823
|
+
cfg.browser = {
|
|
1824
|
+
enabled: true,
|
|
1825
|
+
defaultProfile: 'host-chrome',
|
|
1826
|
+
profiles: { 'host-chrome': { cdpUrl: 'http://127.0.0.1:9222', color: '#4285F4' } },
|
|
1827
|
+
};
|
|
1828
|
+
const wm = buildWorkspaceFileMap({ isVi: true, botName: agent.name || agent.id, botDesc: '', hasBrowser: true, hasScheduler: true, workspacePath: `.openclaw/${workspaceRelForAgent(agent, cfg, projectDir)}/`, agentWorkspaceDir: workspaceRelForAgent(agent, cfg, projectDir), variant: 'single' });
|
|
1829
|
+
const browserDoc = wm['BROWSER.md'] || '# BROWSER';
|
|
1830
|
+
const browserTool = wm['browser-tool.js'] || '';
|
|
1831
|
+
const bf = await readWorkspaceText(projectDir, agent, 'BROWSER.md');
|
|
1832
|
+
await fsp.writeFile(bf.file, browserDoc, 'utf8');
|
|
1833
|
+
const bt = await readWorkspaceText(projectDir, agent, 'browser-tool.js');
|
|
1834
|
+
if (browserTool) await fsp.writeFile(bt.file, browserTool, 'utf8');
|
|
1835
|
+
const af = await readWorkspaceText(projectDir, agent, 'AGENTS.md');
|
|
1836
|
+
const agentsManaged = upsertManagedBlock(af.content, 'BROWSER_LINK', '- Browser docs: `BROWSER.md`');
|
|
1837
|
+
await fsp.writeFile(af.file, agentsManaged, 'utf8');
|
|
1838
|
+
} else {
|
|
1839
|
+
delete cfg.browser;
|
|
1840
|
+
const bf = await readWorkspaceText(projectDir, agent, 'BROWSER.md');
|
|
1841
|
+
if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
|
|
1842
|
+
const bt = await readWorkspaceText(projectDir, agent, 'browser-tool.js');
|
|
1843
|
+
if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
|
|
1844
|
+
const af = await readWorkspaceText(projectDir, agent, 'AGENTS.md');
|
|
1845
|
+
await fsp.writeFile(af.file, removeManagedBlock(af.content, 'BROWSER_LINK'), 'utf8');
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (kind === 'skill' && id === 'cron') {
|
|
1850
|
+
const tf = await readWorkspaceText(projectDir, agent, 'TOOLS.md');
|
|
1851
|
+
if (enabled) {
|
|
1852
|
+
cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
|
|
1853
|
+
cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:automation']));
|
|
1854
|
+
cfg.commands = cfg.commands || {};
|
|
1855
|
+
cfg.commands.ownerAllowFrom = Array.from(new Set([...(cfg.commands.ownerAllowFrom || []), '*']));
|
|
1856
|
+
const cronGuide = `## Cron (OpenClaw)
|
|
1857
|
+
- Dùng tool scheduler/cron native của OpenClaw để tạo job định kỳ.
|
|
1858
|
+
- Không yêu cầu user tự chạy crontab/Task Scheduler trên hos t.
|
|
1859
|
+
- Luôn xác nhận: lịch, múi giờ, nội dung job trước khi lưu.`;
|
|
1860
|
+
await fsp.writeFile(tf.file, upsertManagedBlock(tf.content, 'CRON_GUIDE', cronGuide), 'utf8');
|
|
1861
|
+
} else {
|
|
1862
|
+
if (cfg.tools?.alsoAllow) cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:automation');
|
|
1863
|
+
if (cfg.commands?.ownerAllowFrom) cfg.commands.ownerAllowFrom = cfg.commands.ownerAllowFrom.filter((x) => x !== '*');
|
|
1864
|
+
await fsp.writeFile(tf.file, removeManagedBlock(tf.content, 'CRON_GUIDE'), 'utf8');
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (kind === 'plugin') {
|
|
1869
|
+
cfg.plugins = cfg.plugins || { entries: {} };
|
|
1870
|
+
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
1871
|
+
const pluginAliasMap = {
|
|
1872
|
+
'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
|
|
1873
|
+
'openclaw-n8n-facebook-crawler': ['openclaw-n8n-facebook-crawler', 'openclaw-facebook-crawler', 'n8n-facebook-crawler'],
|
|
1874
|
+
'openclaw-facebook-poster': ['openclaw-facebook-poster', 'openclaw-n8n-facebook-poster', 'facebook-poster'],
|
|
1875
|
+
};
|
|
1876
|
+
const aliases = pluginAliasMap[id] || [id];
|
|
1877
|
+
const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
|
|
1878
|
+
cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
|
|
1879
|
+
cfg.plugins.entries[existingKey].enabled = !!enabled;
|
|
1880
|
+
if (existingKey === 'zalo-mod') {
|
|
1881
|
+
cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
|
|
1882
|
+
cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
|
|
1883
|
+
}
|
|
1884
|
+
// Ensure plugins.allow includes this plugin so gateway loads it
|
|
1885
|
+
cfg.plugins.allow = cfg.plugins.allow || [];
|
|
1886
|
+
const allowIds = aliases.concat(existingKey);
|
|
1887
|
+
for (const aid of allowIds) {
|
|
1888
|
+
if (!cfg.plugins.allow.includes(aid)) cfg.plugins.allow.push(aid);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1893
|
+
return { ok: true };
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function installFeature(projectDir, agentId, kind, id) {
|
|
1897
|
+
if (kind === 'plugin') {
|
|
1898
|
+
let composeDir = null;
|
|
1899
|
+
if (existsSync(join(projectDir, 'docker-compose.yml'))) {
|
|
1900
|
+
composeDir = projectDir;
|
|
1901
|
+
} else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
|
|
1902
|
+
composeDir = join(projectDir, 'docker', 'openclaw');
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (composeDir) {
|
|
1906
|
+
const botContainer = getBotContainerName(projectDir);
|
|
1907
|
+
sendLog(`[plugin] Installing clawhub:${id} inside container ${botContainer}...`);
|
|
1908
|
+
|
|
1909
|
+
let installSuccess = true;
|
|
1910
|
+
const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `cd /root/project && openclaw plugins install clawhub:${id}`], { cwd: projectDir, shell: false }).catch((err) => {
|
|
1911
|
+
const aliases = ['openclaw-zalo-mod', 'zalo-mod', id, id.replace('openclaw-', '')];
|
|
1912
|
+
const folderExists = aliases.some((a) => existsSync(join(projectDir, '.openclaw', 'extensions', a)));
|
|
1913
|
+
if (folderExists) {
|
|
1914
|
+
sendLog(`[plugin] Warning: installation reported errors, but plugin folder successfully written. Proceeding.`);
|
|
1915
|
+
return { stdout: '', stderr: err.message };
|
|
1916
|
+
} else {
|
|
1917
|
+
installSuccess = false;
|
|
1918
|
+
throw err;
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
if (cmdOut) {
|
|
1922
|
+
for (const line of `${cmdOut.stdout}\n${cmdOut.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
1926
|
+
if (existsSync(cfgPath)) {
|
|
1927
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
1928
|
+
cfg.plugins = cfg.plugins || { entries: {} };
|
|
1929
|
+
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
1930
|
+
const pluginAliasMap = {
|
|
1931
|
+
'openclaw-zalo-mod': ['openclaw-zalo-mod', 'zalo-mod'],
|
|
1932
|
+
'openclaw-n8n-facebook-crawler': ['openclaw-n8n-facebook-crawler', 'openclaw-facebook-crawler', 'n8n-facebook-crawler'],
|
|
1933
|
+
'openclaw-facebook-poster': ['openclaw-facebook-poster', 'openclaw-n8n-facebook-poster', 'facebook-poster'],
|
|
1934
|
+
};
|
|
1935
|
+
const aliases = pluginAliasMap[id] || [id];
|
|
1936
|
+
const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
|
|
1937
|
+
cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
|
|
1938
|
+
cfg.plugins.entries[existingKey].enabled = true;
|
|
1939
|
+
if (existingKey === 'zalo-mod') {
|
|
1940
|
+
cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
|
|
1941
|
+
cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
|
|
1942
|
+
// Auto-assign dashboard port = gateway port + 1 to avoid conflicts between bots
|
|
1943
|
+
const gwPort = Number(cfg.gateway?.port) || state.gatewayPort || 18789;
|
|
1944
|
+
cfg.plugins.entries[existingKey].config = cfg.plugins.entries[existingKey].config || {};
|
|
1945
|
+
if (!cfg.plugins.entries[existingKey].config.dashboardPort) {
|
|
1946
|
+
cfg.plugins.entries[existingKey].config.dashboardPort = gwPort + 1;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
// Ensure plugins.allow includes this plugin so gateway loads it
|
|
1950
|
+
cfg.plugins.allow = cfg.plugins.allow || [];
|
|
1951
|
+
const allowIds = aliases.concat(existingKey);
|
|
1952
|
+
for (const aid of allowIds) {
|
|
1953
|
+
if (!cfg.plugins.allow.includes(aid)) cfg.plugins.allow.push(aid);
|
|
1954
|
+
}
|
|
1955
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Auto-expose zalo-mod dashboard port in docker-compose.yml
|
|
1959
|
+
const isZaloMod = id === 'openclaw-zalo-mod' || id === 'zalo-mod';
|
|
1960
|
+
if (isZaloMod) {
|
|
1961
|
+
const composeFile = join(composeDir, 'docker-compose.yml');
|
|
1962
|
+
if (existsSync(composeFile)) {
|
|
1963
|
+
let composeContent = await fsp.readFile(composeFile, 'utf8');
|
|
1964
|
+
const gwPort = Number(state.gatewayPort) || 18789;
|
|
1965
|
+
const dashPort = gwPort + 1;
|
|
1966
|
+
const dashPortMapping = `"127.0.0.1:${dashPort}:${dashPort}"`;
|
|
1967
|
+
if (!composeContent.includes(`:${dashPort}`)) {
|
|
1968
|
+
// Insert dashboard port after the gateway port line
|
|
1969
|
+
const gwPortStr = String(gwPort);
|
|
1970
|
+
composeContent = composeContent.replace(
|
|
1971
|
+
new RegExp(`^(\\s*-\\s*"(?:\\d+:)?${gwPortStr}(?::${gwPortStr})?"\\s*)$`, 'm'),
|
|
1972
|
+
`$1\n - ${dashPortMapping} # zalo-mod dashboard`
|
|
1973
|
+
);
|
|
1974
|
+
await fsp.writeFile(composeFile, composeContent, 'utf8');
|
|
1975
|
+
sendLog(`[plugin] Added dashboard port ${dashPort} to docker-compose.yml`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
sendLog(`[plugin] Restarting docker container to apply plugin...`);
|
|
1981
|
+
if (isZaloMod && composeDir) {
|
|
1982
|
+
// Use docker compose up to apply new port mappings from docker-compose.yml
|
|
1983
|
+
const svcName = getBotServiceName(projectDir);
|
|
1984
|
+
await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--force-recreate', '--no-deps', svcName], { shell: false }).catch(() =>
|
|
1985
|
+
run('docker', ['restart', botContainer], { shell: false })
|
|
1986
|
+
);
|
|
1987
|
+
} else {
|
|
1988
|
+
await run('docker', ['restart', botContainer], { shell: false });
|
|
1989
|
+
}
|
|
1990
|
+
} else {
|
|
1991
|
+
// Fix any legacy config issues first
|
|
1992
|
+
await run('openclaw', ['doctor', '--fix'], { cwd: projectDir, env: openclawProjectEnv(projectDir) }).catch((err) => sendLog(`[plugin] doctor --fix skipped: ${err.message}`));
|
|
1993
|
+
sendLog(`[plugin] Installing clawhub:${id}...`);
|
|
1994
|
+
|
|
1995
|
+
let installSuccess = true;
|
|
1996
|
+
await run('openclaw', ['plugins', 'install', `clawhub:${id}`], {
|
|
1997
|
+
cwd: projectDir,
|
|
1998
|
+
env: openclawProjectEnv(projectDir),
|
|
1999
|
+
resolveOnPattern: /Installed plugin:/
|
|
2000
|
+
}).catch((err) => {
|
|
2001
|
+
// Fallback verification: if the plugin's folder or mapped key is present, it succeeded despite integrity warnings
|
|
2002
|
+
const aliases = ['openclaw-zalo-mod', 'zalo-mod', id, id.replace('openclaw-', '')];
|
|
2003
|
+
const folderExists = aliases.some((a) => existsSync(join(projectDir, '.openclaw', 'extensions', a)));
|
|
2004
|
+
if (folderExists) {
|
|
2005
|
+
sendLog(`[plugin] Warning: installation reported errors, but plugin folder successfully written. Proceeding.`);
|
|
2006
|
+
} else {
|
|
2007
|
+
installSuccess = false;
|
|
2008
|
+
throw err;
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
// Automatically enable it in config after install
|
|
2013
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
2014
|
+
if (existsSync(cfgPath)) {
|
|
2015
|
+
const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
|
|
2016
|
+
cfg.plugins = cfg.plugins || { entries: {} };
|
|
2017
|
+
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
2018
|
+
const pluginAliasMap = {
|
|
2019
|
+
'openclaw-zalo-mod': ['openclaw-zalo-mod', 'zalo-mod'],
|
|
2020
|
+
'openclaw-n8n-facebook-crawler': ['openclaw-n8n-facebook-crawler', 'openclaw-facebook-crawler', 'n8n-facebook-crawler'],
|
|
2021
|
+
'openclaw-facebook-poster': ['openclaw-facebook-poster', 'openclaw-n8n-facebook-poster', 'facebook-poster'],
|
|
2022
|
+
};
|
|
2023
|
+
const aliases = pluginAliasMap[id] || [id];
|
|
2024
|
+
const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
|
|
2025
|
+
cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
|
|
2026
|
+
cfg.plugins.entries[existingKey].enabled = true;
|
|
2027
|
+
if (existingKey === 'zalo-mod') {
|
|
2028
|
+
cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
|
|
2029
|
+
cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
|
|
2030
|
+
// Auto-assign dashboard port = gateway port + 1 to avoid conflicts between bots
|
|
2031
|
+
const gwPort = Number(cfg.gateway?.port) || state.gatewayPort || 18789;
|
|
2032
|
+
cfg.plugins.entries[existingKey].config = cfg.plugins.entries[existingKey].config || {};
|
|
2033
|
+
if (!cfg.plugins.entries[existingKey].config.dashboardPort) {
|
|
2034
|
+
cfg.plugins.entries[existingKey].config.dashboardPort = gwPort + 1;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return { ok: true };
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
async function getFeatureFlags(projectDir, agentId = '') {
|
|
2045
|
+
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
2046
|
+
const cfg = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'))) : {};
|
|
2047
|
+
const aid = agentId || cfg.agents?.list?.[0]?.id || 'bot';
|
|
2048
|
+
const browserOn = !!cfg.browser?.enabled;
|
|
2049
|
+
const cronOn = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
|
|
2050
|
+
if (!browserOn) await applyFeatureToggle(projectDir, aid, 'skill', 'browser', true).catch(() => {});
|
|
2051
|
+
if (!cronOn) await applyFeatureToggle(projectDir, aid, 'skill', 'cron', true).catch(() => {});
|
|
2052
|
+
const fresh = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'))) : {};
|
|
2053
|
+
const freshSaved = {};
|
|
2054
|
+
const installsPath = join(projectDir || '', '.openclaw', 'plugins', 'installs.json');
|
|
2055
|
+
const installs = existsSync(installsPath) ? JSON.parse(await fsp.readFile(installsPath, 'utf8').catch(() => '{}')) : {};
|
|
2056
|
+
const installRecords = installs.installRecords || {};
|
|
2057
|
+
const installedKeys = new Set(Object.keys(installRecords).map((k) => String(k || '').toLowerCase()));
|
|
2058
|
+
const installedSpecs = new Set(Object.values(installRecords).flatMap((r) => {
|
|
2059
|
+
const out = [];
|
|
2060
|
+
const spec = String(r?.spec || '').toLowerCase();
|
|
2061
|
+
const pkg = String(r?.clawhubPackage || '').toLowerCase();
|
|
2062
|
+
const resolved = String(r?.resolvedName || '').toLowerCase();
|
|
2063
|
+
if (spec) out.push(spec);
|
|
2064
|
+
if (pkg) out.push(pkg);
|
|
2065
|
+
if (resolved) out.push(resolved);
|
|
2066
|
+
return out;
|
|
2067
|
+
}));
|
|
2068
|
+
const allowSet = new Set((fresh.plugins?.allow || []).map((x) => String(x || '').toLowerCase()));
|
|
2069
|
+
const entryMap = fresh.plugins?.entries || {};
|
|
2070
|
+
const hasEntry = (aliases = []) => aliases.some((a) => !!entryMap[a]);
|
|
2071
|
+
const isEnabled = (aliases = []) => aliases.some((a) => !!entryMap[a]?.enabled);
|
|
2072
|
+
const isInstalledByRecord = (aliases = []) =>
|
|
2073
|
+
aliases.some((a) =>
|
|
2074
|
+
installedKeys.has(a) ||
|
|
2075
|
+
Array.from(installedSpecs).some((spec) => spec.includes(a)) ||
|
|
2076
|
+
allowSet.has(a)
|
|
2077
|
+
);
|
|
2078
|
+
const aliases = {
|
|
2079
|
+
zalo: ['openclaw-zalo-mod', 'zalo-mod'],
|
|
2080
|
+
crawler: ['openclaw-n8n-facebook-crawler', 'openclaw-facebook-crawler', 'n8n-facebook-crawler'],
|
|
2081
|
+
poster: ['openclaw-facebook-poster', 'openclaw-n8n-facebook-poster', 'facebook-poster'],
|
|
2082
|
+
};
|
|
2083
|
+
const flags = {
|
|
2084
|
+
'skill:browser': freshSaved['skill:browser'] ?? true,
|
|
2085
|
+
'skill:cron': freshSaved['skill:cron'] ?? true,
|
|
2086
|
+
'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
|
|
2087
|
+
'plugin:openclaw-n8n-facebook-crawler': isEnabled(aliases.crawler),
|
|
2088
|
+
'plugin:openclaw-facebook-poster': isEnabled(aliases.poster),
|
|
2089
|
+
};
|
|
2090
|
+
const extensionsDir = join(projectDir || '', '.openclaw', 'extensions');
|
|
2091
|
+
const extensionDirExists = (aliases = []) =>
|
|
2092
|
+
aliases.some((a) => existsSync(join(extensionsDir, a)));
|
|
2093
|
+
const isActuallyInstalled = (aliases = []) =>
|
|
2094
|
+
extensionDirExists(aliases) || isInstalledByRecord(aliases);
|
|
2095
|
+
const installed = {
|
|
2096
|
+
'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
|
|
2097
|
+
'plugin:openclaw-n8n-facebook-crawler': isActuallyInstalled(aliases.crawler),
|
|
2098
|
+
'plugin:openclaw-facebook-poster': isActuallyInstalled(aliases.poster),
|
|
2099
|
+
};
|
|
2100
|
+
return { flags, installed };
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function serveStatic(req, res) {
|
|
2104
|
+
const url = new URL(req.url, 'http://local');
|
|
2105
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
2106
|
+
if (pathname === '/') pathname = '/index.html';
|
|
2107
|
+
const file = resolve(WEB_DIR, pathname.slice(1));
|
|
2108
|
+
if (!file.startsWith(WEB_DIR) || !existsSync(file)) return false;
|
|
2109
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
|
|
2110
|
+
res.writeHead(200, {
|
|
2111
|
+
'content-type': types[extname(file)] || 'application/octet-stream',
|
|
2112
|
+
'cache-control': 'no-store, no-cache, must-revalidate',
|
|
2113
|
+
pragma: 'no-cache',
|
|
2114
|
+
expires: '0',
|
|
2115
|
+
});
|
|
2116
|
+
createReadStream(file).pipe(res);
|
|
2117
|
+
return true;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
async function handler(req, res, rootProjectDir) {
|
|
2121
|
+
try {
|
|
2122
|
+
const url = new URL(req.url, 'http://local');
|
|
2123
|
+
if (url.pathname === '/api/install/logs') {
|
|
2124
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
2125
|
+
logClients.add(res);
|
|
2126
|
+
res.write(`data: ${JSON.stringify({ line: 'log stream connected', ts: new Date().toISOString() })}\n\n`);
|
|
2127
|
+
req.on('close', () => logClients.delete(res));
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
if (url.pathname === '/api/system' && req.method === 'GET') {
|
|
2131
|
+
const osChoice = detectOs();
|
|
2132
|
+
const [nodeStatus, npmStatus, dockerStatus, currentVersions] = await Promise.all([commandExists('node'), commandExists('npm'), commandExists('docker', ['version', '--format', '{{.Server.Version}}']), getCurrentRuntimeVersions()]);
|
|
2133
|
+
const projectDir = state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json')) ? state.projectDir : null;
|
|
2134
|
+
const projectVersions = await resolveProjectRuntimeVersions(projectDir, state.mode).catch(() => null);
|
|
2135
|
+
const mergedVersions = {
|
|
2136
|
+
openclaw: projectVersions?.openclaw || currentVersions.openclaw || OPENCLAW_NPM_SPEC,
|
|
2137
|
+
nineRouter: projectVersions?.nineRouter || currentVersions.nineRouter || NINE_ROUTER_NPM_SPEC,
|
|
2138
|
+
node: projectVersions?.node || currentVersions.node || String(nodeStatus?.output || '').trim(),
|
|
2139
|
+
};
|
|
2140
|
+
const projects = await discoverProjects(rootProjectDir).catch(() => []);
|
|
2141
|
+
return json(res, { os: osChoice, platform: process.platform, arch: process.arch, recommendedMode: recommendedMode(osChoice), node: nodeStatus, npm: npmStatus, docker: dockerStatus, versions: { desiredOpenclaw: OPENCLAW_NPM_SPEC, desiredNineRouter: NINE_ROUTER_NPM_SPEC, currentOpenclaw: mergedVersions.openclaw, currentNineRouter: mergedVersions.nineRouter, currentNode: mergedVersions.node, openclaw: mergedVersions.openclaw, nineRouter: mergedVersions.nineRouter, node: mergedVersions.node }, projects });
|
|
2142
|
+
}
|
|
2143
|
+
if (url.pathname === '/api/projects/discover' && req.method === 'GET') {
|
|
2144
|
+
return json(res, { ok: true, projects: await discoverProjects(rootProjectDir).catch(() => []) });
|
|
2145
|
+
}
|
|
2146
|
+
if (url.pathname === '/api/project/pick-folder' && req.method === 'POST') {
|
|
2147
|
+
return json(res, await pickProjectFolder());
|
|
2148
|
+
}
|
|
2149
|
+
if (url.pathname === '/api/project/delete' && req.method === 'POST') {
|
|
2150
|
+
const body = await readJson(req);
|
|
2151
|
+
return json(res, await deleteProjectFolder(body.projectDir, rootProjectDir));
|
|
2152
|
+
}
|
|
2153
|
+
if (url.pathname === '/api/install' && req.method === 'POST') {
|
|
2154
|
+
if (state.installing) return json(res, { ok: false, error: 'Install already running' }, 409);
|
|
2155
|
+
const body = await readJson(req);
|
|
2156
|
+
const osChoice = body.os || detectOs();
|
|
2157
|
+
const mode = body.mode || recommendedMode(osChoice);
|
|
2158
|
+
const projectDir = body.projectDir ? resolve(String(body.projectDir)) : resolve(rootProjectDir, body.projectName || DEFAULT_PROJECT_NAME);
|
|
2159
|
+
|
|
2160
|
+
// Auto-allocate unique, free ports to avoid collision (reserving gatewayPort + 1 for Zalo-mod UI)
|
|
2161
|
+
const projects = await discoverProjects(rootProjectDir).catch(() => []);
|
|
2162
|
+
const usedPorts = new Set();
|
|
2163
|
+
for (const p of projects) {
|
|
2164
|
+
const gw = Number(p.gatewayPort);
|
|
2165
|
+
if (gw) {
|
|
2166
|
+
usedPorts.add(gw);
|
|
2167
|
+
usedPorts.add(gw + 1); // Zalo-mod UI port of existing project
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
const usedRouterPorts = new Set(projects.map(p => Number(p.routerPort)).filter(Boolean));
|
|
2171
|
+
|
|
2172
|
+
let gatewayPort = 18789;
|
|
2173
|
+
while (usedPorts.has(gatewayPort) || usedPorts.has(gatewayPort + 1)) {
|
|
2174
|
+
gatewayPort++;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
let routerPort = 20128;
|
|
2178
|
+
while (usedRouterPorts.has(routerPort)) {
|
|
2179
|
+
routerPort++;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
state.gatewayPort = gatewayPort;
|
|
2183
|
+
state.routerPort = routerPort;
|
|
2184
|
+
state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
|
|
2185
|
+
state.routerUrl = `http://127.0.0.1:${routerPort}`;
|
|
2186
|
+
|
|
2187
|
+
installCore({ osChoice, mode, projectDir, gatewayPort, routerPort }).catch(() => {});
|
|
2188
|
+
state.projectDir = projectDir;
|
|
2189
|
+
state.mode = mode;
|
|
2190
|
+
state.os = osChoice;
|
|
2191
|
+
saveState(rootProjectDir);
|
|
2192
|
+
return json(res, { ok: true, projectDir, state });
|
|
2193
|
+
}
|
|
2194
|
+
if (url.pathname === '/api/project/connect' && req.method === 'POST') {
|
|
2195
|
+
const body = await readJson(req);
|
|
2196
|
+
return json(res, await connectExistingProject(body.projectDir, rootProjectDir));
|
|
2197
|
+
}
|
|
2198
|
+
if (url.pathname === '/api/project/connect-picked' && req.method === 'POST') {
|
|
2199
|
+
const body = await readJson(req);
|
|
2200
|
+
return json(res, await connectPickedProject(body.projectName, rootProjectDir));
|
|
2201
|
+
}
|
|
2202
|
+
if (url.pathname === '/api/bot/status' && req.method === 'GET') {
|
|
2203
|
+
await resolveProjectDir(rootProjectDir);
|
|
2204
|
+
return json(res, await buildBotStatus());
|
|
2205
|
+
}
|
|
2206
|
+
if (url.pathname === '/api/bot/credentials' && req.method === 'PUT') {
|
|
2207
|
+
const body = await readJson(req);
|
|
2208
|
+
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
2209
|
+
const credentials = await updateBotCredentials(projectDir, body);
|
|
2210
|
+
sendLog('Credentials updated: 9Router API key');
|
|
2211
|
+
return json(res, { ok: true, credentials });
|
|
2212
|
+
}
|
|
2213
|
+
if (url.pathname === '/api/runtime/update' && req.method === 'POST') {
|
|
2214
|
+
const body = await readJson(req);
|
|
2215
|
+
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
2216
|
+
const target = body.target === '9router' ? '9router' : 'openclaw';
|
|
2217
|
+
sendLog(`[update] Updating ${target}...`);
|
|
2218
|
+
const result = await updateRuntime(target, projectDir);
|
|
2219
|
+
sendLog(`[update] ${target} update completed (${result.mode})`);
|
|
2220
|
+
return json(res, result);
|
|
2221
|
+
}
|
|
2222
|
+
if (url.pathname === '/api/bot/create' && req.method === 'POST') {
|
|
2223
|
+
const body = await readJson(req);
|
|
2224
|
+
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
2225
|
+
const result = await createBotInProject(projectDir, body, { mode: state.mode, os: state.os });
|
|
2226
|
+
await saveState(rootProjectDir);
|
|
2227
|
+
sendLog(`✅ Bot created: ${result.agentId} (${result.channel})`);
|
|
2228
|
+
if (result.warning) sendLog(`⚠️ ${result.warning}`);
|
|
2229
|
+
await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
|
|
2230
|
+
|
|
2231
|
+
if (result.channel === 'telegram') {
|
|
2232
|
+
const botContainer = getBotContainerName(projectDir);
|
|
2233
|
+
const token = String(body.token || '').trim();
|
|
2234
|
+
sendLog(`[telegram] Registering Telegram channel via CLI inside ${botContainer}...`);
|
|
2235
|
+
try {
|
|
2236
|
+
const regResult = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `cd /root/project && openclaw channels add telegram --token "${token}"`], { cwd: projectDir, shell: false });
|
|
2237
|
+
sendLog(`[telegram] CLI registration output:\n${regResult.stdout}\n${regResult.stderr}`);
|
|
2238
|
+
sendLog(`[telegram] Restarting ${botContainer} container to load the registered channel...`);
|
|
2239
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[telegram] Container restart failed: ${err.message}`));
|
|
2240
|
+
sendLog(`[telegram] ${botContainer} restarted. Try chatting with your Telegram bot now.`);
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
sendLog(`[telegram] Warning: CLI registration failed: ${err.message}`);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (result.channel === 'zalo-personal') {
|
|
2247
|
+
result.loginStarted = true;
|
|
2248
|
+
result.loginHint = 'Generating Zalo QR. Keep this modal open...';
|
|
2249
|
+
result.zaloQrDataUrl = '';
|
|
2250
|
+
// Delay login start to let the recreated container fully boot gateway + plugins
|
|
2251
|
+
setTimeout(async () => {
|
|
2252
|
+
try {
|
|
2253
|
+
const login = await startZaloUserLogin(projectDir, state.mode);
|
|
2254
|
+
if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
|
|
2255
|
+
if (login?.message) sendLog(`[zalouser] ${login.message}`);
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
sendLog(`[zalouser] Login failed: ${err.message}`);
|
|
2258
|
+
}
|
|
2259
|
+
}, 5000);
|
|
2260
|
+
}
|
|
2261
|
+
return json(res, result);
|
|
2262
|
+
}
|
|
2263
|
+
if (url.pathname.startsWith('/api/bot/') && req.method === 'PUT' && !url.pathname.startsWith('/api/bot/files/')) {
|
|
2264
|
+
const agentId = decodeURIComponent(url.pathname.split('/').pop() || '');
|
|
2265
|
+
const body = await readJson(req);
|
|
2266
|
+
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
2267
|
+
const result = await updateBotInProject(projectDir, agentId, body, { mode: state.mode, os: state.os });
|
|
2268
|
+
await saveState(rootProjectDir);
|
|
2269
|
+
await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
|
|
2270
|
+
return json(res, result);
|
|
2271
|
+
}
|
|
2272
|
+
if (url.pathname === '/api/zalo/login' && req.method === 'POST') {
|
|
2273
|
+
const projectDir = await resolveProjectDir(rootProjectDir);
|
|
2274
|
+
setImmediate(async () => {
|
|
2275
|
+
try {
|
|
2276
|
+
const login = await startZaloUserLogin(projectDir, state.mode);
|
|
2277
|
+
if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
|
|
2278
|
+
if (login?.message) sendLog(`[zalouser] ${login.message}`);
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
sendLog(`[zalouser] Login failed: ${err.message}`);
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
|
|
2284
|
+
}
|
|
2285
|
+
if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
|
|
2286
|
+
const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
|
|
2287
|
+
const projectDir = await resolveProjectDir(rootProjectDir);
|
|
2288
|
+
const result = await deleteBotInProject(projectDir, agentId);
|
|
2289
|
+
sendLog(`? Bot deleted: ${agentId}`);
|
|
2290
|
+
await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
|
|
2291
|
+
return json(res, result);
|
|
2292
|
+
}
|
|
2293
|
+
if (url.pathname === '/api/bot/files' && req.method === 'GET') {
|
|
2294
|
+
await resolveProjectDir(rootProjectDir);
|
|
2295
|
+
return json(res, { files: state.projectDir ? await listMarkdownFiles(state.projectDir, url.searchParams.get('agentId') || '') : [] });
|
|
2296
|
+
}
|
|
2297
|
+
if (url.pathname.startsWith('/api/bot/files/') && state.projectDir) {
|
|
2298
|
+
const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
|
|
2299
|
+
const file = safeJoin(state.projectDir, name);
|
|
2300
|
+
if (req.method === 'GET') return json(res, { name, content: await fsp.readFile(file, 'utf8') });
|
|
2301
|
+
if (req.method === 'PUT') {
|
|
2302
|
+
if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
|
|
2303
|
+
const body = await readJson(req);
|
|
2304
|
+
await fsp.writeFile(file, String(body.content || ''), 'utf8');
|
|
2305
|
+
return json(res, { ok: true });
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
if (url.pathname === '/api/catalog' && req.method === 'GET') return json(res, {
|
|
2309
|
+
skills: [
|
|
2310
|
+
{ name: 'Browser', slug: 'browser' },
|
|
2311
|
+
{ name: 'Cron', slug: 'cron' },
|
|
2312
|
+
],
|
|
2313
|
+
plugins: [
|
|
2314
|
+
{ name: 'openclaw-zalo-mod', package: 'openclaw-zalo-mod' },
|
|
2315
|
+
{ name: 'openclaw-n8n-facebook-crawler', package: 'openclaw-n8n-facebook-crawler' },
|
|
2316
|
+
{ name: 'openclaw-facebook-poster', package: 'openclaw-facebook-poster' },
|
|
2317
|
+
]
|
|
2318
|
+
});
|
|
2319
|
+
if (url.pathname === '/api/features' && req.method === 'GET') {
|
|
2320
|
+
const projectDir = await resolveProjectDir(rootProjectDir);
|
|
2321
|
+
return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
|
|
2322
|
+
}
|
|
2323
|
+
if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
|
|
2324
|
+
const body = await readJson(req);
|
|
2325
|
+
const projectDir = await resolveProjectDir(rootProjectDir);
|
|
2326
|
+
return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
|
|
2327
|
+
}
|
|
2328
|
+
if (url.pathname === '/api/features/install' && req.method === 'POST') {
|
|
2329
|
+
const body = await readJson(req);
|
|
2330
|
+
const projectDir = await resolveProjectDir(rootProjectDir);
|
|
2331
|
+
return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
|
|
2332
|
+
}
|
|
2333
|
+
if (await serveStatic(req, res)) return;
|
|
2334
|
+
json(res, { error: 'Not found' }, 404);
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
json(res, { error: err.message }, err.status || 500);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function findPort(host, preferredPort) {
|
|
2341
|
+
return new Promise((resolve, reject) => {
|
|
2342
|
+
const server = net.createServer();
|
|
2343
|
+
server.unref();
|
|
2344
|
+
server.on('error', () => resolve(findPort(host, preferredPort + 1)));
|
|
2345
|
+
server.listen(preferredPort, host, () => {
|
|
2346
|
+
const port = server.address().port;
|
|
2347
|
+
server.close(() => resolve(port));
|
|
2348
|
+
});
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function openUrl(url) {
|
|
2353
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
2354
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
2355
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore', shell: false, windowsHide: true });
|
|
2356
|
+
child.unref();
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
|
|
2360
|
+
const port = await findPort(host, preferredPort);
|
|
2361
|
+
const server = http.createServer((req, res) => handler(req, res, projectDir));
|
|
2362
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
2363
|
+
const url = `http://${host}:${port}`;
|
|
2364
|
+
console.log(`OpenClaw Setup UI: ${url}`);
|
|
2365
|
+
console.log('Legacy CLI: create-openclaw-bot legacy');
|
|
2366
|
+
if (openBrowser) openUrl(url);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
export { createBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey };
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
|