create-openclaw-bot 5.11.1 → 5.13.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 +51 -36
- package/README.vi.md +51 -36
- package/dist/server/local-server.js +366 -360
- package/dist/setup/data/channels.js +13 -14
- package/dist/setup/shared/bot-config-gen.js +39 -32
- package/dist/setup/shared/common-gen.js +11 -0
- package/dist/setup/shared/docker-gen.js +27 -63
- package/dist/setup/shared/workspace-gen.js +9 -26
- package/dist/web/app.js +42 -15
- package/dist/web/styles.css +3 -0
- package/package.json +2 -2
|
@@ -13,10 +13,10 @@ function loadSharedModule(modulePath, globalName) {
|
|
|
13
13
|
if (loaded && Object.keys(loaded).length > 0) return loaded;
|
|
14
14
|
return globalThis[globalName] || loaded || {};
|
|
15
15
|
}
|
|
16
|
-
const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs
|
|
17
|
-
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
16
|
+
const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
|
|
17
|
+
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
18
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');
|
|
19
|
+
const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
|
|
20
20
|
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
21
21
|
|
|
22
22
|
async function syncExecApprovals(projectDir, cfg) {
|
|
@@ -259,6 +259,7 @@ const STATE_FILE = '.openclaw-setup-state.json';
|
|
|
259
259
|
const DEFAULT_MODEL = 'smart-route';
|
|
260
260
|
const logClients = new Set();
|
|
261
261
|
let zaloLoginInFlight = false;
|
|
262
|
+
let zaloLoginChild = null; // active `openclaw channels login` process (for cancel)
|
|
262
263
|
let activeServerInstance = null;
|
|
263
264
|
// Captured at startup so a self-restart (update button) re-binds the SAME host/port,
|
|
264
265
|
// letting the browser tab reconnect to the new UI instead of hanging.
|
|
@@ -948,7 +949,7 @@ function ensureConfigShape(cfg) {
|
|
|
948
949
|
cfg.channels = cfg.channels || {};
|
|
949
950
|
cfg.bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
|
950
951
|
cfg.plugins = cfg.plugins || { entries: { 'memory-core': { config: { dreaming: { enabled: false } } } } };
|
|
951
|
-
// Preserve plugins.allow — needed for
|
|
952
|
+
// Preserve plugins.allow — needed for external plugins such as Zalo Connect.
|
|
952
953
|
if (!cfg.plugins.allow) cfg.plugins.allow = [];
|
|
953
954
|
cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
|
|
954
955
|
return cfg;
|
|
@@ -968,30 +969,19 @@ function ensureTelegramChannel(cfg) {
|
|
|
968
969
|
});
|
|
969
970
|
}
|
|
970
971
|
|
|
971
|
-
function
|
|
972
|
-
cfg
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
},
|
|
981
|
-
dmPolicy: 'open',
|
|
982
|
-
groupPolicy: 'open',
|
|
983
|
-
historyLimit: 50,
|
|
984
|
-
groups: {
|
|
985
|
-
'*': { enabled: true, requireMention: false },
|
|
986
|
-
},
|
|
987
|
-
allowFrom: ['*'],
|
|
988
|
-
groupAllowFrom: ['*'],
|
|
989
|
-
};
|
|
990
|
-
// Ensure zalouser is in plugins.entries (plugins.allow is deprecated)
|
|
972
|
+
function zaloBackendForConfig(cfg) {
|
|
973
|
+
return cfg?.channels?.['zalo-connect']?.enabled ? 'zalo-connect' : '';
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function ensureZaloConnectChannel(cfg) {
|
|
977
|
+
// Secure defaults — DM pairing, no groups enabled
|
|
978
|
+
// until the owner picks them. Existing zalo-connect config is preserved as-is.
|
|
979
|
+
cfg.channels['zalo-connect'] = cfg.channels['zalo-connect'] || buildZaloConnectChannelConfig();
|
|
980
|
+
cfg.channels['zalo-connect'].enabled = true;
|
|
991
981
|
cfg.plugins.entries = cfg.plugins.entries || {};
|
|
992
|
-
cfg.plugins.entries
|
|
982
|
+
cfg.plugins.entries['zalo-connect'] = cfg.plugins.entries['zalo-connect'] || { enabled: true };
|
|
993
983
|
cfg.plugins.allow = cfg.plugins.allow || [];
|
|
994
|
-
if (!cfg.plugins.allow.includes('
|
|
984
|
+
if (!cfg.plugins.allow.includes('zalo-connect')) cfg.plugins.allow.push('zalo-connect');
|
|
995
985
|
}
|
|
996
986
|
|
|
997
987
|
function ensureFbMessengerChannel(cfg, pageId, appId) {
|
|
@@ -1208,7 +1198,7 @@ function mapAgentChannel(agent, cfg) {
|
|
|
1208
1198
|
if (agent && typeof agent === 'object' && ['telegram', 'zalo-personal', 'zalo-bot', 'fb-messenger'].includes(agent.channel)) return agent.channel;
|
|
1209
1199
|
const binding = (cfg.bindings || []).find((b) => b.agentId === agentId);
|
|
1210
1200
|
const ch = binding?.match?.channel;
|
|
1211
|
-
if (ch === '
|
|
1201
|
+
if (ch === 'zalo-connect') return 'zalo-personal';
|
|
1212
1202
|
if (ch === 'zalo') return 'zalo-bot';
|
|
1213
1203
|
if (ch === 'telegram') return 'telegram';
|
|
1214
1204
|
if (ch === 'fb-messenger') return 'fb-messenger';
|
|
@@ -1220,17 +1210,23 @@ function mapAgentChannels(agent, cfg) {
|
|
|
1220
1210
|
if (agent?.channel && ['telegram', 'zalo-personal', 'zalo-bot', 'fb-messenger'].includes(agent.channel)) return [agent.channel];
|
|
1221
1211
|
const channels = (cfg.bindings || [])
|
|
1222
1212
|
.filter((b) => b.agentId === agent?.id)
|
|
1223
|
-
.map((b) => b.match?.channel === '
|
|
1213
|
+
.map((b) => b.match?.channel === 'zalo-connect' ? 'zalo-personal' : b.match?.channel === 'zalo' ? 'zalo-bot' : b.match?.channel)
|
|
1224
1214
|
.filter((ch) => ['telegram', 'zalo-personal', 'zalo-bot', 'fb-messenger'].includes(ch));
|
|
1225
1215
|
if (channels.length) return Array.from(new Set(channels));
|
|
1226
1216
|
const enabled = [];
|
|
1227
1217
|
if (cfg.channels?.telegram?.enabled) enabled.push('telegram');
|
|
1228
|
-
if (cfg.channels?.
|
|
1218
|
+
if (cfg.channels?.['zalo-connect']?.enabled) enabled.push('zalo-personal');
|
|
1229
1219
|
if (cfg.channels?.zalo?.enabled) enabled.push('zalo-bot');
|
|
1230
1220
|
if (cfg.channels?.['fb-messenger']?.enabled) enabled.push('fb-messenger');
|
|
1231
1221
|
return enabled.length === 1 ? enabled : [mapAgentChannel(agent, cfg)];
|
|
1232
1222
|
}
|
|
1233
1223
|
|
|
1224
|
+
function bindingChannelId(channel = '') {
|
|
1225
|
+
if (channel === 'zalo-personal') return 'zalo-connect';
|
|
1226
|
+
if (channel === 'zalo-bot') return 'zalo';
|
|
1227
|
+
return channel;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1234
1230
|
async function listConfiguredBots(projectDir) {
|
|
1235
1231
|
const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
|
|
1236
1232
|
if (!projectDir || !existsSync(cfgPath)) return [];
|
|
@@ -1244,11 +1240,14 @@ async function listConfiguredBots(projectDir) {
|
|
|
1244
1240
|
const env = await readProjectEnv(projectDir);
|
|
1245
1241
|
const hasOwnWorkspace = !!agent.workspace;
|
|
1246
1242
|
const identityName = usableIdentityName(identity.name);
|
|
1247
|
-
return mapAgentChannels(agent, cfg).map((channel) =>
|
|
1243
|
+
return mapAgentChannels(agent, cfg).map((channel) => {
|
|
1244
|
+
const binding = (cfg.bindings || []).find((b) => b.agentId === agent.id && b.match?.channel === bindingChannelId(channel));
|
|
1245
|
+
return ({
|
|
1248
1246
|
id: agent.id,
|
|
1249
1247
|
name: (hasOwnWorkspace ? identityName : agent.name) || agent.name || identityName || agent.id,
|
|
1250
1248
|
role: identity.role || meta.role || agent.role || agent.desc || agent.description || '',
|
|
1251
1249
|
channel,
|
|
1250
|
+
accountId: binding?.match?.accountId || 'default',
|
|
1252
1251
|
workspace: agent.workspace || `.openclaw/${workspaceRelForAgent(agent, cfg, projectDir)}`,
|
|
1253
1252
|
agentDir: agent.agentDir,
|
|
1254
1253
|
persona: meta.persona || '',
|
|
@@ -1260,7 +1259,8 @@ async function listConfiguredBots(projectDir) {
|
|
|
1260
1259
|
pageAccessToken: channel === 'fb-messenger' ? (env.FB_MESSENGER_PAGE_ACCESS_TOKEN || '') : '',
|
|
1261
1260
|
appSecret: channel === 'fb-messenger' ? (env.FB_MESSENGER_APP_SECRET || '') : '',
|
|
1262
1261
|
verifyToken: channel === 'fb-messenger' ? (env.FB_MESSENGER_VERIFY_TOKEN || '') : '',
|
|
1263
|
-
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
1264
|
}));
|
|
1265
1265
|
return rows.flat();
|
|
1266
1266
|
}
|
|
@@ -1446,22 +1446,13 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
1446
1446
|
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
|
|
1447
1447
|
await appendEnvValue(projectDir, accountId === 'default' ? 'TELEGRAM_BOT_TOKEN' : `TELEGRAM_BOT_TOKEN_${agentId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`, token);
|
|
1448
1448
|
} else if (channel === 'zalo-personal') {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const defAcct = zu.defaultAccount || 'default';
|
|
1453
|
-
// Make legacy catch-all zalouser bindings (no accountId) account-specific so a second
|
|
1454
|
-
// account isn't swallowed by them. The existing single bot owns the default account.
|
|
1455
|
-
for (const b of cfg.bindings) {
|
|
1456
|
-
if (b.match?.channel === 'zalouser' && !b.match.accountId) b.match.accountId = defAcct;
|
|
1449
|
+
const existingZaloConnectBindings = cfg.bindings.filter((b) => b.match?.channel === 'zalo-connect').length;
|
|
1450
|
+
if (existingZaloConnectBindings > 0) {
|
|
1451
|
+
throw httpError(400, 'OpenClaw Zalo Connect hiện hỗ trợ 1 tài khoản Zalo mỗi project. Hãy dùng bot Zalo hiện có hoặc tạo project riêng cho tài khoản thứ hai.');
|
|
1457
1452
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
accountId = existingZaloBindings === 0 ? defAcct : agentId;
|
|
1462
|
-
zu.enabled = true;
|
|
1463
|
-
zu.accounts[accountId] = zu.accounts[accountId] || { enabled: true, profile: accountId };
|
|
1464
|
-
cfg.bindings.push({ agentId, match: { channel: 'zalouser', accountId } });
|
|
1453
|
+
ensureZaloConnectChannel(cfg);
|
|
1454
|
+
accountId = 'default';
|
|
1455
|
+
cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId } });
|
|
1465
1456
|
} else if (channel === 'fb-messenger') {
|
|
1466
1457
|
// Token handling (user token → permanent Page token) is done by the fb-messenger
|
|
1467
1458
|
// plugin itself; here we just persist whatever the user supplied plus the App ID
|
|
@@ -1502,7 +1493,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
1502
1493
|
workspacePath: `/home/node/project/.openclaw/${workspaceDir}`,
|
|
1503
1494
|
channel,
|
|
1504
1495
|
hasZaloMod: channel === 'zalo-personal',
|
|
1505
|
-
|
|
1496
|
+
zaloBackend: zaloBackendForConfig(cfg),
|
|
1506
1497
|
hasScheduler,
|
|
1507
1498
|
hasImageGen,
|
|
1508
1499
|
});
|
|
@@ -1558,8 +1549,8 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
1558
1549
|
cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
|
|
1559
1550
|
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
|
|
1560
1551
|
} else if (channel === 'zalo-personal') {
|
|
1561
|
-
|
|
1562
|
-
cfg.bindings.push({ agentId, match: { channel: '
|
|
1552
|
+
ensureZaloConnectChannel(cfg);
|
|
1553
|
+
cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId: 'default' } });
|
|
1563
1554
|
} else if (channel === 'fb-messenger') {
|
|
1564
1555
|
ensureFbMessengerChannel(cfg, String(body.pageId || '').trim(), String(body.appId || '').trim());
|
|
1565
1556
|
cfg.bindings.push({ agentId, match: { channel: 'fb-messenger', accountId: 'default' } });
|
|
@@ -1624,7 +1615,7 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
1624
1615
|
workspacePath: `/home/node/project/.openclaw/${workspaceDir}`,
|
|
1625
1616
|
channel,
|
|
1626
1617
|
hasZaloMod: channel === 'zalo-personal',
|
|
1627
|
-
|
|
1618
|
+
zaloBackend: zaloBackendForConfig(cfg),
|
|
1628
1619
|
hasScheduler,
|
|
1629
1620
|
hasImageGen,
|
|
1630
1621
|
});
|
|
@@ -1668,7 +1659,7 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
|
|
|
1668
1659
|
return false;
|
|
1669
1660
|
}
|
|
1670
1661
|
|
|
1671
|
-
async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000) {
|
|
1662
|
+
async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
|
|
1672
1663
|
const started = Date.now();
|
|
1673
1664
|
// Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
|
|
1674
1665
|
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")})';
|
|
@@ -1682,254 +1673,306 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
|
|
|
1682
1673
|
const status = String(out.stdout || '').trim();
|
|
1683
1674
|
if (status === 'READY') {
|
|
1684
1675
|
const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
|
|
1685
|
-
const output = (pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '');
|
|
1686
|
-
if (
|
|
1676
|
+
const output = ((pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '')).toLowerCase();
|
|
1677
|
+
if (channelKeywords.some((kw) => output.includes(kw))) {
|
|
1687
1678
|
ready = true;
|
|
1688
1679
|
break;
|
|
1689
1680
|
}
|
|
1690
|
-
if (attempts > 2) sendLog('[
|
|
1681
|
+
if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
|
|
1691
1682
|
} else {
|
|
1692
|
-
if (attempts > 2 && attempts % 3 === 0) sendLog('[
|
|
1683
|
+
if (attempts > 2 && attempts % 3 === 0) sendLog('[zalo-connect] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
|
|
1693
1684
|
}
|
|
1694
1685
|
} catch {}
|
|
1695
1686
|
await new Promise((r) => setTimeout(r, 5000));
|
|
1696
1687
|
}
|
|
1697
1688
|
if (!ready) {
|
|
1698
|
-
sendLog('[
|
|
1689
|
+
sendLog('[zalo-connect] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
|
|
1699
1690
|
}
|
|
1700
1691
|
return ready;
|
|
1701
1692
|
}
|
|
1702
1693
|
|
|
1703
|
-
async function
|
|
1704
|
-
|
|
1705
|
-
if (
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1694
|
+
async function startZaloLogin(projectDir, agentId = "") {
|
|
1695
|
+
const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
|
|
1696
|
+
if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
|
|
1697
|
+
const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
|
|
1698
|
+
const binding = (cfg.bindings || []).find((b) =>
|
|
1699
|
+
(!agentId || b.agentId === agentId) && b.match?.channel === "zalo-connect"
|
|
1700
|
+
);
|
|
1701
|
+
return startZaloConnectLogin(projectDir, binding?.match?.accountId || "default");
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// ── OpenClaw Zalo Connect QR login (new projects) ────────────────────────────────────────────
|
|
1705
|
+
// zalo-connect prints the QR two ways: ASCII art on stdout and a PNG written to the
|
|
1706
|
+
// container's tmpdir, announced with "QR image saved at: /tmp/zalo-connect-qr-<id>.png".
|
|
1707
|
+
// We watch stdout for that line, read the PNG out of the container, and push it to
|
|
1708
|
+
// the UI modal as a data URL ([zalo-connect:qr] log tag). Reconnect NEVER reinstalls the
|
|
1709
|
+
// plugin — install runs only when extensions/zalo-connect is absent, and always with the
|
|
1710
|
+
// pinned spec (never `latest`).
|
|
1711
|
+
async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
1712
|
+
if (zaloLoginInFlight) {
|
|
1713
|
+
return { message: 'Zalo login is already running. Keep this modal open...' };
|
|
1714
|
+
}
|
|
1715
|
+
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
1716
|
+
if (!existsSync(composeFile)) {
|
|
1717
|
+
throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
|
|
1718
|
+
}
|
|
1719
|
+
zaloLoginInFlight = true;
|
|
1720
|
+
const botContainer = getBotContainerName(projectDir);
|
|
1721
|
+
sendLog(`[zalo-connect] Preparing QR login for account [${accountId}]...`);
|
|
1722
|
+
try {
|
|
1723
|
+
// NEVER poke the container while it is still booting: OpenClaw runs first-boot
|
|
1724
|
+
// migrations under a state lease, and a docker exec/restart mid-migration wedges
|
|
1725
|
+
// the lease and crash-loops the gateway. Wait for the container, then for the
|
|
1726
|
+
// gateway to report the zalo-connect channel (the entrypoint installs the pinned
|
|
1727
|
+
// plugin itself on first boot), and only fall back to an exec-install when the
|
|
1728
|
+
// gateway is up but the plugin is genuinely absent (projects created before the
|
|
1729
|
+
// backend-aware entrypoint existed).
|
|
1730
|
+
const containerUp = await waitForDockerContainer(botContainer, 90000);
|
|
1731
|
+
if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
|
|
1732
|
+
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
|
|
1733
|
+
if (!gatewayReady) {
|
|
1734
|
+
const check = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', '[ -d "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect" ] && echo OK || echo MISSING'], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'ERR' }));
|
|
1735
|
+
if (String(check.stdout || '').trim() === 'MISSING') {
|
|
1736
|
+
sendLog(`[zalo-connect] Plugin missing — installing pinned ${ZALO_CONNECT_PLUGIN_SPEC}...`);
|
|
1737
|
+
const repo = String(ZALO_CONNECT_PLUGIN_SPEC).split('#')[0];
|
|
1738
|
+
const ref = String(ZALO_CONNECT_PLUGIN_SPEC).split('#')[1] || ZALO_CONNECT_VERSION;
|
|
1739
|
+
const installCmd = `tmp=/tmp/openclaw-plugin-zalo-connect-${ref}; rm -rf "$tmp"; git clone --depth 1 --branch "${ref}" "${repo}" "$tmp" && rm -rf "$tmp/.git" && cd /home/node/project && openclaw plugins install "$tmp" 2>&1`;
|
|
1740
|
+
const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
|
|
1741
|
+
const instOut = `${inst.stdout}\n${inst.stderr}`;
|
|
1742
|
+
for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
|
|
1743
|
+
if (/installed plugin/i.test(instOut)) {
|
|
1744
|
+
// Gateway must reload to pick the plugin up — safe here: the gateway is past
|
|
1745
|
+
// its boot (we only reach this branch when it answered the exec above).
|
|
1746
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
|
|
1747
|
+
await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
|
|
1748
|
+
} else {
|
|
1749
|
+
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo" sau khi container ổn định.');
|
|
1713
1750
|
}
|
|
1714
1751
|
}
|
|
1715
|
-
} catch (e) {
|
|
1716
|
-
sendLog(`[zalouser] Warning: Failed to parse openclaw.json: ${e.message}`);
|
|
1717
1752
|
}
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
if (existsSync(cfgPath)) {
|
|
1722
|
-
const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
|
|
1723
|
-
const binding = (cfg.bindings || []).find(b => b.agentId === state.activeBotId && b.match?.channel === 'zalouser');
|
|
1724
|
-
if (binding?.match?.accountId) {
|
|
1725
|
-
profile = binding.match.accountId;
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
} catch (e) {}
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
zaloLoginInFlight = false;
|
|
1755
|
+
throw err;
|
|
1729
1756
|
}
|
|
1730
1757
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
)
|
|
1741
|
-
|
|
1758
|
+
sendLog('[zalo-connect] Generating Zalo QR. The image will appear automatically.');
|
|
1759
|
+
const loginCmd = `cd /home/node/project && openclaw channels login --channel zalo-connect --account ${accountId} --verbose`;
|
|
1760
|
+
let qrSent = false;
|
|
1761
|
+
let loginDone = false;
|
|
1762
|
+
|
|
1763
|
+
const pushQrFromContainer = async (pngPath) => {
|
|
1764
|
+
const js = `const fs=require('fs');const p=${JSON.stringify(pngPath)};try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));}}catch{}`;
|
|
1765
|
+
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false }).catch(() => ({ stdout: '' }));
|
|
1766
|
+
const b64 = extractCompletePngBase64(out.stdout);
|
|
1767
|
+
if (b64.length > 100) {
|
|
1768
|
+
qrSent = true;
|
|
1769
|
+
sendLog(`[zalo-connect:qr] data:image/png;base64,${b64}`);
|
|
1770
|
+
sendLog('[zalo-connect] Scan this QR with the Zalo app.');
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1742
1773
|
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1774
|
+
const isQrAsciiArt = (line) => /^[\s▀▄█▌▐░▒▓]+$/.test(line);
|
|
1775
|
+
const handleLine = (line) => {
|
|
1776
|
+
const qrFile = line.match(/QR image saved at:\s*(\S+\.png)/i);
|
|
1777
|
+
if (qrFile) {
|
|
1778
|
+
pushQrFromContainer(qrFile[1]).catch(() => {});
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
if (isQrAsciiArt(line)) return; // don't flood the UI modal with terminal QR art
|
|
1782
|
+
sendLog(`[zalo-connect] ${line}`);
|
|
1783
|
+
if (/login successful|login completed|logged in/i.test(line)) loginDone = true;
|
|
1784
|
+
};
|
|
1785
|
+
// Retry loop: right after a boot the gateway may still refuse `channels login`
|
|
1786
|
+
// ("container is restarting", "still preparing"), so give it a few spaced attempts.
|
|
1787
|
+
const MAX_ATTEMPTS = 3;
|
|
1788
|
+
const RETRY_DELAYS = [0, 10000, 20000];
|
|
1789
|
+
let attempt = 0;
|
|
1790
|
+
const runAttempt = () => {
|
|
1791
|
+
attempt++;
|
|
1792
|
+
if (attempt > 1) sendLog(`[zalo-connect] Retry ${attempt}/${MAX_ATTEMPTS}...`);
|
|
1793
|
+
const child = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
|
|
1794
|
+
zaloLoginChild = child;
|
|
1795
|
+
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
|
|
1796
|
+
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
|
|
1797
|
+
child.on('error', (err) => sendLog(`[zalo-connect] Login process failed: ${err.message}`));
|
|
1798
|
+
child.on('close', async (code) => {
|
|
1799
|
+
const wasCancelled = child.killed && zaloLoginChild === null;
|
|
1800
|
+
if (zaloLoginChild === child) zaloLoginChild = null;
|
|
1801
|
+
sendLog(`[zalo-connect] Login process exited ${code}`);
|
|
1802
|
+
if (loginDone) {
|
|
1803
|
+
sendLog(`[zalo-connect] Login saved. Restarting ${botContainer} so the Zalo channel connects...`);
|
|
1804
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalo-connect] Container restart failed: ${err.message}`));
|
|
1805
|
+
sendLog(`[zalo-connect] ${botContainer} restarted. Try sending a Zalo message now.`);
|
|
1806
|
+
zaloLoginInFlight = false;
|
|
1807
|
+
} else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
|
|
1808
|
+
const delay = RETRY_DELAYS[attempt] || 15000;
|
|
1809
|
+
sendLog(`[zalo-connect] QR chưa sẵn sàng — thử lại sau ${delay / 1000}s...`);
|
|
1810
|
+
setTimeout(runAttempt, delay);
|
|
1811
|
+
} else {
|
|
1812
|
+
if (!qrSent && !wasCancelled) sendLog('[zalo-connect] Login ended without a QR. Click "Đăng nhập Zalo" to retry.');
|
|
1813
|
+
zaloLoginInFlight = false;
|
|
1814
|
+
}
|
|
1755
1815
|
});
|
|
1756
|
-
|
|
1816
|
+
};
|
|
1817
|
+
runAttempt();
|
|
1818
|
+
|
|
1819
|
+
return { message: 'Generating Zalo QR. The image will appear automatically.' };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Cancel an in-flight Zalo QR login (modal closed). Kills the CLI login process so
|
|
1823
|
+
// no orphaned login keeps polling Zalo; safe to call when nothing is running.
|
|
1824
|
+
function cancelZaloLogin() {
|
|
1825
|
+
const child = zaloLoginChild;
|
|
1826
|
+
if (child) {
|
|
1827
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
1828
|
+
zaloLoginChild = null;
|
|
1829
|
+
zaloLoginInFlight = false;
|
|
1830
|
+
sendLog('[zalo-connect] Login cancelled.');
|
|
1831
|
+
return { ok: true, cancelled: true };
|
|
1757
1832
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
const j = JSON.parse(fs.readFileSync(inst, 'utf8'));
|
|
1777
|
-
const z = j.plugins.find(x => x.pluginId === 'zalouser');
|
|
1778
|
-
if (z && z.channels && z.channels.includes('zalouser')) {
|
|
1779
|
-
console.log('OK');
|
|
1780
|
-
} else {
|
|
1781
|
-
console.log('MISSING_CHANNELS');
|
|
1833
|
+
return { ok: true, cancelled: false };
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function buildZaloHealthSnapshot(cfg = {}, statusJson = null, credentialNames = null, options = {}) {
|
|
1837
|
+
const containerRunning = options.containerRunning !== false;
|
|
1838
|
+
const textStatus = String(options.textStatus || '');
|
|
1839
|
+
const agents = new Map((cfg.agents?.list || []).map((agent) => [String(agent.id), agent]));
|
|
1840
|
+
const bindings = (cfg.bindings || []).filter((binding) => binding.match?.channel === 'zalo-connect');
|
|
1841
|
+
const runtimeAccounts = Array.isArray(statusJson?.channelAccounts?.['zalo-connect'])
|
|
1842
|
+
? statusJson.channelAccounts['zalo-connect']
|
|
1843
|
+
: [];
|
|
1844
|
+
const expected = [];
|
|
1845
|
+
const seen = new Set();
|
|
1846
|
+
for (const binding of bindings) {
|
|
1847
|
+
const accountId = String(binding.match?.accountId || 'default');
|
|
1848
|
+
if (seen.has(accountId)) continue;
|
|
1849
|
+
seen.add(accountId);
|
|
1850
|
+
expected.push({ accountId, agentId: String(binding.agentId || '') });
|
|
1782
1851
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
sendLog(status === 'MISSING' ? '[zalouser] Plugin not found — installing @openclaw/zalouser...' : '[zalouser] Plugin registry missing channels array — fixing install via CLI...');
|
|
1789
|
-
|
|
1790
|
-
const fixScript = `
|
|
1791
|
-
const fs=require('fs');
|
|
1792
|
-
const cp=require('child_process');
|
|
1793
|
-
const cfg='/home/node/project/.openclaw/openclaw.json';
|
|
1794
|
-
const bk='/home/node/project/.openclaw/openclaw.json.zalo-backup';
|
|
1795
|
-
try{if(fs.existsSync(cfg))fs.copyFileSync(cfg,bk);}catch(e){}
|
|
1796
|
-
// Detect gateway version and pin zalouser plugin to match, preventing createSetupTranslator mismatch
|
|
1797
|
-
let gatewayVer='';
|
|
1798
|
-
try{gatewayVer=cp.execSync('openclaw --version 2>/dev/null',{encoding:'utf8'}).trim().replace(/[^0-9.]/g,'');}catch(e){}
|
|
1799
|
-
const pluginSpec=gatewayVer ? '@openclaw/zalouser@'+gatewayVer : '@openclaw/zalouser';
|
|
1800
|
-
console.log('Installing plugin via CLI: '+pluginSpec+'...');
|
|
1801
|
-
try{cp.execSync('cd /home/node/project && openclaw plugins install '+pluginSpec+' --force',{stdio:'inherit'});}catch(e){
|
|
1802
|
-
// Fallback: try without version pin if exact version not found on registry
|
|
1803
|
-
if(gatewayVer){console.log('Pinned version failed, trying latest...');try{cp.execSync('cd /home/node/project && openclaw plugins install @openclaw/zalouser --force',{stdio:'inherit'});}catch(e2){console.error('Install failed');}}
|
|
1804
|
-
else{console.error('Install failed');}
|
|
1805
|
-
}
|
|
1806
|
-
try{
|
|
1807
|
-
if(fs.existsSync(bk)){
|
|
1808
|
-
const b=JSON.parse(fs.readFileSync(bk,'utf8'));
|
|
1809
|
-
const c=JSON.parse(fs.readFileSync(cfg,'utf8'));
|
|
1810
|
-
const keys=['agents','channels','bindings','gateway','models'];
|
|
1811
|
-
for(const k of keys){if(b[k])c[k]=b[k];}
|
|
1812
|
-
if(b.plugins){
|
|
1813
|
-
if(b.plugins.allow)c.plugins={...c.plugins,allow:b.plugins.allow};
|
|
1814
|
-
if(b.plugins.deny)c.plugins={...c.plugins,deny:b.plugins.deny};
|
|
1815
|
-
if(b.plugins.entries)c.plugins={...c.plugins,entries:b.plugins.entries};
|
|
1816
|
-
}
|
|
1817
|
-
if(!c.plugins)c.plugins={};if(!c.plugins.entries)c.plugins.entries={};
|
|
1818
|
-
if(!c.plugins.entries.zalouser)c.plugins.entries.zalouser={};
|
|
1819
|
-
c.plugins.entries.zalouser.enabled=true;
|
|
1820
|
-
fs.writeFileSync(cfg,JSON.stringify(c,null,2)+'\\n','utf8');
|
|
1821
|
-
fs.unlinkSync(bk);
|
|
1822
|
-
console.log('Config protected and restored.');
|
|
1852
|
+
for (const runtime of runtimeAccounts) {
|
|
1853
|
+
const accountId = String(runtime?.accountId || 'default');
|
|
1854
|
+
if (seen.has(accountId)) continue;
|
|
1855
|
+
seen.add(accountId);
|
|
1856
|
+
expected.push({ accountId, agentId: '' });
|
|
1823
1857
|
}
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
}
|
|
1858
|
+
|
|
1859
|
+
const credentialSet = Array.isArray(credentialNames)
|
|
1860
|
+
? new Set(credentialNames.map((name) => String(name || '').toLowerCase()))
|
|
1861
|
+
: null;
|
|
1862
|
+
const fallbackLines = textStatus.split(/\r?\n/).filter((line) => /zalo[- ]connect/i.test(line));
|
|
1863
|
+
const accounts = expected.map(({ accountId, agentId }) => {
|
|
1864
|
+
const runtime = runtimeAccounts.find((item) => String(item?.accountId || 'default') === accountId) || null;
|
|
1865
|
+
const line = fallbackLines.find((item) => accountId === 'default'
|
|
1866
|
+
? /\bdefault\b/i.test(item) || fallbackLines.length === 1
|
|
1867
|
+
: item.toLowerCase().includes(accountId.toLowerCase())) || '';
|
|
1868
|
+
const fallbackRunning = /running|connected|ready|\bok\b/i.test(line);
|
|
1869
|
+
const fallbackFailed = /stopped|disconnected|error|failed/i.test(line);
|
|
1870
|
+
const configured = runtime ? runtime.configured === true : !!line && !fallbackFailed;
|
|
1871
|
+
const running = runtime ? runtime.running === true : fallbackRunning;
|
|
1872
|
+
const lastError = runtime?.lastError || (fallbackFailed ? line.trim() : null);
|
|
1873
|
+
const credentialFile = accountId === 'default'
|
|
1874
|
+
? 'zalo-connect-credentials.json'
|
|
1875
|
+
: `zalo-connect-credentials-${accountId}.json`;
|
|
1876
|
+
const fileSaved = credentialSet ? credentialSet.has(credentialFile.toLowerCase()) : false;
|
|
1877
|
+
const agent = agents.get(agentId);
|
|
1878
|
+
return {
|
|
1879
|
+
accountId,
|
|
1880
|
+
agentId,
|
|
1881
|
+
name: agent?.name || agentId || accountId,
|
|
1882
|
+
configured,
|
|
1883
|
+
running,
|
|
1884
|
+
lastError,
|
|
1885
|
+
sessionSaved: fileSaved || runtime?.configured === true,
|
|
1886
|
+
};
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
const total = accounts.length;
|
|
1890
|
+
const running = accounts.filter((account) => account.running).length;
|
|
1891
|
+
const configured = accounts.filter((account) => account.configured).length;
|
|
1892
|
+
const failed = accounts.filter((account) => account.lastError || (!account.running && account.configured)).length;
|
|
1893
|
+
let channelStatus = 'unknown';
|
|
1894
|
+
if (!containerRunning) channelStatus = 'container-stopped';
|
|
1895
|
+
else if (total > 0 && running === total) channelStatus = 'connected';
|
|
1896
|
+
else if (running > 0) channelStatus = 'partial';
|
|
1897
|
+
else if (total > 0 && (failed > 0 || configured > 0)) channelStatus = 'disconnected';
|
|
1898
|
+
else if (statusJson || fallbackLines.length) channelStatus = 'starting';
|
|
1899
|
+
|
|
1900
|
+
return {
|
|
1901
|
+
backend: cfg.channels?.['zalo-connect']?.enabled ? 'zalo-connect' : '',
|
|
1902
|
+
containerRunning,
|
|
1903
|
+
channelStatus,
|
|
1904
|
+
channelStatusLine: fallbackLines.join(' | '),
|
|
1905
|
+
summary: { total, running, configured, failed },
|
|
1906
|
+
accounts,
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// ── Zalo health snapshot for the dashboard ──────────────────────────────────────
|
|
1911
|
+
// Runtime JSON is authoritative and account-aware. Text parsing remains only as a
|
|
1912
|
+
// compatibility fallback for older OpenClaw builds.
|
|
1913
|
+
async function getZaloHealth(projectDir) {
|
|
1914
|
+
const meta = {
|
|
1915
|
+
supportedVersion: ZALO_CONNECT_VERSION,
|
|
1916
|
+
installedVersion: null,
|
|
1917
|
+
zaloModInstalled: false,
|
|
1918
|
+
zaloModVersion: null,
|
|
1919
|
+
};
|
|
1920
|
+
if (!projectDir) return { ...buildZaloHealthSnapshot({}, null, null, { containerRunning: false }), ...meta };
|
|
1921
|
+
let cfg = null;
|
|
1922
|
+
try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
|
|
1923
|
+
if (!cfg) return { ...buildZaloHealthSnapshot({}, null, null, { containerRunning: false }), ...meta };
|
|
1924
|
+
meta.zaloModInstalled = !!(cfg.plugins?.entries?.['zalo-mod'] || cfg.plugins?.entries?.['openclaw-zalo-mod'])
|
|
1925
|
+
|| existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-mod'));
|
|
1926
|
+
meta.zaloModVersion = await getInstalledPluginVersion(projectDir, ['zalo-mod', 'openclaw-zalo-mod']) || null;
|
|
1927
|
+
if (meta.zaloModVersion) meta.zaloModInstalled = true;
|
|
1928
|
+
|
|
1929
|
+
const botContainer = getBotContainerName(projectDir);
|
|
1930
|
+
if (cfg.channels?.['zalo-connect']?.enabled) {
|
|
1931
|
+
const manifestHost = join(projectDir, '.openclaw', 'extensions', 'zalo-connect', 'openclaw.plugin.json');
|
|
1932
|
+
try {
|
|
1933
|
+
meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
|
|
1934
|
+
} catch {
|
|
1935
|
+
try {
|
|
1936
|
+
const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'cat "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect/openclaw.plugin.json" 2>/dev/null'], { cwd: projectDir, shell: false, timeout: 8000 });
|
|
1937
|
+
meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
|
|
1938
|
+
} catch {}
|
|
1842
1939
|
}
|
|
1843
1940
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1941
|
+
|
|
1942
|
+
let containerRunning = false;
|
|
1943
|
+
try {
|
|
1944
|
+
const r = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', botContainer], { shell: false, timeout: 8000 });
|
|
1945
|
+
containerRunning = String(r.stdout || '').trim() === 'true';
|
|
1946
|
+
} catch {}
|
|
1947
|
+
let statusJson = null;
|
|
1948
|
+
let textStatus = '';
|
|
1949
|
+
let credentialNames = null;
|
|
1950
|
+
if (containerRunning) {
|
|
1951
|
+
try {
|
|
1952
|
+
const r = await runCapture('docker', ['exec', botContainer, 'openclaw', 'channels', 'status', '--json'], { cwd: projectDir, shell: false, timeout: 20000 });
|
|
1953
|
+
statusJson = parseJsonText(String(r.stdout || '').trim(), null);
|
|
1954
|
+
} catch {}
|
|
1955
|
+
if (!statusJson) {
|
|
1956
|
+
try {
|
|
1957
|
+
const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false, timeout: 20000 });
|
|
1958
|
+
textStatus = String(r.stdout || '');
|
|
1959
|
+
} catch {}
|
|
1858
1960
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
const MAX_LOGIN_ATTEMPTS = 4;
|
|
1871
|
-
const RETRY_DELAYS = [0, 8000, 15000, 20000];
|
|
1872
|
-
let restartAfterLogin = false;
|
|
1873
|
-
let loginAttempt = 0;
|
|
1874
|
-
let sent = false;
|
|
1875
|
-
|
|
1876
|
-
// Start QR file polling in parallel (runs across all retry attempts)
|
|
1877
|
-
let tries = 0;
|
|
1878
|
-
const poll = setInterval(async () => {
|
|
1879
|
-
if (sent || tries++ > 120) {
|
|
1880
|
-
clearInterval(poll);
|
|
1881
|
-
if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login or recreate Zalo User bot.');
|
|
1882
|
-
return;
|
|
1883
|
-
}
|
|
1884
|
-
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{}}`;
|
|
1885
|
-
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
|
|
1886
|
-
const b64 = extractCompletePngBase64(out.stdout);
|
|
1887
|
-
if (b64.length > 100) {
|
|
1888
|
-
sent = true;
|
|
1889
|
-
clearInterval(poll);
|
|
1890
|
-
sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
|
|
1891
|
-
sendLog('[zalouser] Scan this QR with the Zalo app.');
|
|
1961
|
+
try {
|
|
1962
|
+
const script = "const fs=require('fs'),path=require('path'),os=require('os');const d=path.join(os.homedir(),'.openclaw');let a=[];try{a=fs.readdirSync(d).filter(n=>/^zalo-connect-credentials(?:-[^.]+)?\\.json$/i.test(n))}catch{}process.stdout.write(JSON.stringify(a))";
|
|
1963
|
+
const r = await runCapture('docker', ['exec', botContainer, 'node', '-e', script], { cwd: projectDir, shell: false, timeout: 8000 });
|
|
1964
|
+
credentialNames = parseJsonText(String(r.stdout || '[]').trim(), []);
|
|
1965
|
+
} catch {}
|
|
1966
|
+
try {
|
|
1967
|
+
const versions = await getContainerExtensionVersions(projectDir);
|
|
1968
|
+
const zaloModVersion = versions['zalo-mod'] || versions['openclaw-zalo-mod'] || '';
|
|
1969
|
+
if (zaloModVersion) {
|
|
1970
|
+
meta.zaloModInstalled = true;
|
|
1971
|
+
meta.zaloModVersion = zaloModVersion;
|
|
1892
1972
|
}
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
const runLoginAttempt = () => {
|
|
1896
|
-
loginAttempt++;
|
|
1897
|
-
if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
|
|
1898
|
-
const child = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
|
|
1899
|
-
const handleLoginLine = (line) => {
|
|
1900
|
-
sendLog(line);
|
|
1901
|
-
if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
|
|
1902
|
-
};
|
|
1903
|
-
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
1904
|
-
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
1905
|
-
child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}`));
|
|
1906
|
-
child.on('close', async (code) => {
|
|
1907
|
-
sendLog(`[zalouser] Login process exited ${code}`);
|
|
1908
|
-
if (code === 0 || restartAfterLogin || sent) {
|
|
1909
|
-
// Success or QR already found by poll
|
|
1910
|
-
if (restartAfterLogin) {
|
|
1911
|
-
sendLog(`[zalouser] Login saved. Restarting ${botContainer} container so Zalo User can receive messages...`);
|
|
1912
|
-
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalouser] Container restart failed: ${err.message}`));
|
|
1913
|
-
sendLog(`[zalouser] ${botContainer} restarted. Try sending a Zalo message now.`);
|
|
1914
|
-
}
|
|
1915
|
-
zaloLoginInFlight = false;
|
|
1916
|
-
} else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
|
|
1917
|
-
// Failed with "Still preparing QR" — retry after delay
|
|
1918
|
-
const delay = RETRY_DELAYS[loginAttempt] || 10000;
|
|
1919
|
-
sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
|
|
1920
|
-
setTimeout(runLoginAttempt, delay);
|
|
1921
|
-
} else {
|
|
1922
|
-
sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
|
|
1923
|
-
zaloLoginInFlight = false;
|
|
1924
|
-
}
|
|
1925
|
-
});
|
|
1926
|
-
};
|
|
1927
|
-
|
|
1928
|
-
runLoginAttempt();
|
|
1929
|
-
return { message: 'Generating Zalo QR. The image will appear automatically.' };
|
|
1973
|
+
} catch {}
|
|
1930
1974
|
}
|
|
1931
|
-
|
|
1932
|
-
throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
|
|
1975
|
+
return { ...buildZaloHealthSnapshot(cfg, statusJson, credentialNames, { containerRunning, textStatus }), ...meta };
|
|
1933
1976
|
}
|
|
1934
1977
|
|
|
1935
1978
|
function getBotServiceName(projectDir) {
|
|
@@ -1991,12 +2034,12 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
1991
2034
|
const routerPort = state.routerPort || 20128;
|
|
1992
2035
|
const osChoice = await resolveProjectHostOs(projectDir);
|
|
1993
2036
|
|
|
1994
|
-
// Detect
|
|
2037
|
+
// Detect the single supported personal-Zalo backend from openclaw.json.
|
|
1995
2038
|
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
1996
|
-
let
|
|
2039
|
+
let zaloBackend = '';
|
|
1997
2040
|
try {
|
|
1998
2041
|
const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
|
|
1999
|
-
|
|
2042
|
+
if (cfg.channels?.['zalo-connect']?.enabled) zaloBackend = 'zalo-connect';
|
|
2000
2043
|
} catch {}
|
|
2001
2044
|
|
|
2002
2045
|
// Regenerate with detected settings
|
|
@@ -2012,8 +2055,8 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2012
2055
|
singleComposeName: composeName,
|
|
2013
2056
|
singleAppContainerName: botContainer,
|
|
2014
2057
|
singleRouterContainerName: routerContainer,
|
|
2058
|
+
zaloBackend,
|
|
2015
2059
|
runtimeCommandParts: [
|
|
2016
|
-
hasZalo ? 'ensure_zalouser' : '',
|
|
2017
2060
|
'while true; do sleep 5; openclaw devices approve --latest 2>/dev/null || true; done >/dev/null 2>&1 &',
|
|
2018
2061
|
].filter(Boolean),
|
|
2019
2062
|
plainSingleExtraHosts: true,
|
|
@@ -2089,37 +2132,6 @@ async function recreateDockerBot(projectDir) {
|
|
|
2089
2132
|
await run('docker', ['compose', '-f', composeFile, 'up', '-d', '--build', '--force-recreate', serviceName], { cwd: projectDir });
|
|
2090
2133
|
await waitForDockerContainer(containerName);
|
|
2091
2134
|
|
|
2092
|
-
// Automatically run Zalo sticker-mention patch if skill is enabled and agent is zalo-personal
|
|
2093
|
-
try {
|
|
2094
|
-
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
2095
|
-
if (existsSync(cfgPath)) {
|
|
2096
|
-
const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
|
|
2097
|
-
const stickerMentionOn = !!cfg.skills?.entries?.['sticker-mention']?.enabled;
|
|
2098
|
-
if (stickerMentionOn) {
|
|
2099
|
-
for (const a of cfg.agents?.list || []) {
|
|
2100
|
-
const binding = (cfg.bindings || []).find((b) => b.agentId === a.id);
|
|
2101
|
-
const channel = binding?.match?.channel || 'telegram';
|
|
2102
|
-
if (channel === 'zalo-personal' || channel === 'zalouser') {
|
|
2103
|
-
const workspaceDir = workspaceRelForAgent(a, cfg, projectDir) || `workspace-${a.id}`;
|
|
2104
|
-
let mentionsJsPath = join(projectDir, '.openclaw', workspaceDir, 'skills/zalo-sticker-mention/mentions.js');
|
|
2105
|
-
let containerJsPath = `/home/node/project/.openclaw/${workspaceDir}/skills/zalo-sticker-mention/mentions.js`;
|
|
2106
|
-
if (!existsSync(mentionsJsPath)) {
|
|
2107
|
-
mentionsJsPath = join(projectDir, '.openclaw', workspaceDir, 'skills/sticker-mention/mentions.js');
|
|
2108
|
-
containerJsPath = `/home/node/project/.openclaw/${workspaceDir}/skills/sticker-mention/mentions.js`;
|
|
2109
|
-
}
|
|
2110
|
-
if (existsSync(mentionsJsPath)) {
|
|
2111
|
-
sendLog(`[zalo-patch] Automatically running mentions.js inside container ${containerName}...`);
|
|
2112
|
-
const patchCmd = await runCapture('docker', ['exec', containerName, 'node', containerJsPath], { cwd: projectDir, shell: false });
|
|
2113
|
-
sendLog(`[zalo-patch] Output: ${patchCmd.stdout || ''} ${patchCmd.stderr || ''}`);
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
} catch (err) {
|
|
2120
|
-
sendLog(`[zalo-patch] Failed to auto-run mentions.js: ${err.message}`);
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
2135
|
// Container was rebuilt/recreated: runtime, versions and extension versions may all have changed.
|
|
2124
2136
|
probeCacheClear();
|
|
2125
2137
|
return true;
|
|
@@ -2311,7 +2323,10 @@ async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 1878
|
|
|
2311
2323
|
const agentMetas = [];
|
|
2312
2324
|
const common = { channelKey: 'telegram', providerKey: '9router', model: DEFAULT_MODEL, deployMode: mode, osChoice, selectedSkills, skills: dataExport.SKILLS || [], agentMetas, gatewayPort, routerPort };
|
|
2313
2325
|
const cfg = buildOpenclawJson(common);
|
|
2314
|
-
|
|
2326
|
+
// A core project has no channel account yet. Keep its environment shared/credential-free;
|
|
2327
|
+
// writing the literal <your_bot_token> placeholder makes OpenClaw auto-detect Telegram and
|
|
2328
|
+
// emit a misleading "plugin not enabled" warning before the user has created any bot.
|
|
2329
|
+
const env = buildEnvFileContent({ ...common, apiKey: '', botToken: '', isSharedEnv: true });
|
|
2315
2330
|
await fsp.writeFile(join(openclawHome, 'openclaw.json'), JSON.stringify(cfg, null, 2), 'utf8');
|
|
2316
2331
|
await fsp.writeFile(join(projectDir, '.env'), env, 'utf8');
|
|
2317
2332
|
await syncExecApprovals(projectDir, cfg);
|
|
@@ -2558,12 +2573,16 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2558
2573
|
await fsp.mkdir(dockerDir, { recursive: true });
|
|
2559
2574
|
const envContent = existsSync(rootEnvPath)
|
|
2560
2575
|
? await fsp.readFile(rootEnvPath, 'utf8')
|
|
2561
|
-
: buildEnvFileContent({ channelKey: 'telegram', providerKey: '9router', deployMode: mode, osChoice, selectedSkills: ['memory', 'web-search', 'scheduler'], skills: dataExport.SKILLS || [], agentMetas: [], apiKey: '', botToken: '' });
|
|
2576
|
+
: buildEnvFileContent({ channelKey: 'telegram', providerKey: '9router', deployMode: mode, osChoice, selectedSkills: ['memory', 'web-search', 'scheduler'], skills: dataExport.SKILLS || [], agentMetas: [], apiKey: '', botToken: '', isSharedEnv: true });
|
|
2562
2577
|
await fsp.writeFile(dockerEnvPath, envContent, 'utf8');
|
|
2563
2578
|
sendLog(`Docker env ready: ${dockerEnvPath}`);
|
|
2564
2579
|
await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
|
|
2565
2580
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2566
|
-
|
|
2581
|
+
// The full compose start already created the bot container. Recreating it here used to
|
|
2582
|
+
// interrupt OpenClaw's first-boot state migration and leave its five-minute lease behind,
|
|
2583
|
+
// making a brand-new project appear to crash-loop until the lease expired. The config is
|
|
2584
|
+
// bind-mounted, so the resolved 9Router key does not require an immediate second recreate.
|
|
2585
|
+
probeCacheClear();
|
|
2567
2586
|
}
|
|
2568
2587
|
state.installed = true;
|
|
2569
2588
|
sendLog('✅ Install completed');
|
|
@@ -3050,7 +3069,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
|
|
|
3050
3069
|
for (const a of cfg.agents.list) {
|
|
3051
3070
|
const sf = await readWorkspaceText(projectDir, a, 'skills/cronjob/SKILL.md');
|
|
3052
3071
|
await fsp.mkdir(dirname(sf.file), { recursive: true });
|
|
3053
|
-
await fsp.writeFile(sf.file, buildCronjobSkillMd(true), 'utf8');
|
|
3072
|
+
await fsp.writeFile(sf.file, buildCronjobSkillMd(true, 'zalo-connect'), 'utf8');
|
|
3054
3073
|
}
|
|
3055
3074
|
} else {
|
|
3056
3075
|
if (cfg.tools?.alsoAllow) cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:automation');
|
|
@@ -3072,47 +3091,22 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
|
|
|
3072
3091
|
}
|
|
3073
3092
|
}
|
|
3074
3093
|
|
|
3075
|
-
// Folder-based per-bot skills (image-gen /
|
|
3094
|
+
// Folder-based per-bot skills (image-gen / learning-memory). These load
|
|
3076
3095
|
// from <workspace>/skills, so each bot's copy is independent. Toggling here affects ONLY the
|
|
3077
3096
|
// active bot's workspace folder. The global skills.entries[id].enabled flag is kept true
|
|
3078
3097
|
// while ANY bot still has the folder (openclaw needs it true to load the skill at all).
|
|
3079
|
-
if (kind === 'skill' && (id === 'image-gen' || id === '
|
|
3080
|
-
const slugMap = { 'image-gen': 'infographic-generator', '
|
|
3098
|
+
if (kind === 'skill' && (id === 'image-gen' || id === 'learning-memory')) {
|
|
3099
|
+
const slugMap = { 'image-gen': 'infographic-generator', 'learning-memory': 'learning-memory' };
|
|
3081
3100
|
const slug = slugMap[id];
|
|
3082
3101
|
const rel = workspaceRelForAgent(agent, cfg, projectDir) || `workspace-${agent.id}`;
|
|
3083
3102
|
const folder = join(projectDir, '.openclaw', rel, 'skills', slug);
|
|
3084
3103
|
const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
|
|
3085
3104
|
|
|
3086
|
-
// Sticker skill needs its outbound patch applied on enable / restored on disable (Zalo personal only).
|
|
3087
|
-
const applyStickerPatch = async (restore) => {
|
|
3088
|
-
if (id !== 'sticker-mention') return;
|
|
3089
|
-
const channel = (cfg.bindings || []).find((b) => b.agentId === agent.id)?.match?.channel || '';
|
|
3090
|
-
if (channel !== 'zalo-personal' && channel !== 'zalouser') return;
|
|
3091
|
-
let hostJs = join(projectDir, '.openclaw', rel, 'skills/zalo-sticker-mention/mentions.js');
|
|
3092
|
-
let contJs = `/home/node/project/.openclaw/${rel}/skills/zalo-sticker-mention/mentions.js`;
|
|
3093
|
-
if (!existsSync(hostJs)) {
|
|
3094
|
-
hostJs = join(projectDir, '.openclaw', rel, 'skills/sticker-mention/mentions.js');
|
|
3095
|
-
contJs = `/home/node/project/.openclaw/${rel}/skills/sticker-mention/mentions.js`;
|
|
3096
|
-
}
|
|
3097
|
-
if (!existsSync(hostJs)) return;
|
|
3098
|
-
const extra = restore ? ['--restore'] : [];
|
|
3099
|
-
try {
|
|
3100
|
-
if (hasDocker) {
|
|
3101
|
-
await runCapture('docker', ['exec', getBotContainerName(projectDir), 'node', contJs, ...extra], { cwd: projectDir, shell: false });
|
|
3102
|
-
} else {
|
|
3103
|
-
await run('node', [hostJs, ...extra], { cwd: projectDir });
|
|
3104
|
-
}
|
|
3105
|
-
} catch (e) { sendLog(`[zalo-patch] ${restore ? 'restore' : 'patch'} failed: ${e.message}`); }
|
|
3106
|
-
};
|
|
3107
|
-
|
|
3108
3105
|
let installedNow = false;
|
|
3109
3106
|
if (enabled) {
|
|
3110
3107
|
// Enable for THIS bot only: ensure its workspace has the skill folder.
|
|
3111
3108
|
if (!existsSync(folder)) { await installFeature(projectDir, agent.id, 'skill', id); installedNow = true; }
|
|
3112
|
-
await applyStickerPatch(false);
|
|
3113
3109
|
} else {
|
|
3114
|
-
// Disable for THIS bot only: restore any patch, then remove just this bot's folder.
|
|
3115
|
-
await applyStickerPatch(true);
|
|
3116
3110
|
await fsp.rm(folder, { recursive: true, force: true }).catch(() => {});
|
|
3117
3111
|
}
|
|
3118
3112
|
|
|
@@ -3267,7 +3261,6 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3267
3261
|
if (kind === 'skill') {
|
|
3268
3262
|
const skillSlugMap = {
|
|
3269
3263
|
'image-gen': 'infographic-generator',
|
|
3270
|
-
'sticker-mention': 'zalo-sticker-mention',
|
|
3271
3264
|
'learning-memory': 'learning-memory',
|
|
3272
3265
|
};
|
|
3273
3266
|
const slug = skillSlugMap[id] || id;
|
|
@@ -3324,6 +3317,10 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3324
3317
|
}
|
|
3325
3318
|
|
|
3326
3319
|
if (kind === 'plugin') {
|
|
3320
|
+
const installSpec = pluginInstallSpec(id);
|
|
3321
|
+
const installArgs = ['plugins', 'install', installSpec, '--force'];
|
|
3322
|
+
if (installSpec.startsWith('clawhub:')) installArgs.push('--acknowledge-clawhub-risk');
|
|
3323
|
+
|
|
3327
3324
|
let composeDir = null;
|
|
3328
3325
|
if (existsSync(join(projectDir, 'docker-compose.yml'))) {
|
|
3329
3326
|
composeDir = projectDir;
|
|
@@ -3333,9 +3330,9 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3333
3330
|
|
|
3334
3331
|
if (composeDir) {
|
|
3335
3332
|
const botContainer = getBotContainerName(projectDir);
|
|
3336
|
-
sendLog(`[plugin] Installing/updating ${
|
|
3333
|
+
sendLog(`[plugin] Installing/updating ${installSpec} inside container ${botContainer}...`);
|
|
3337
3334
|
|
|
3338
|
-
const cmd = `cd /home/node/project && openclaw
|
|
3335
|
+
const cmd = `cd /home/node/project && openclaw ${installArgs.join(' ')}`;
|
|
3339
3336
|
const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
|
|
3340
3337
|
|
|
3341
3338
|
if (cmdOut) {
|
|
@@ -3427,10 +3424,10 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3427
3424
|
} else {
|
|
3428
3425
|
// Fix any legacy config issues first
|
|
3429
3426
|
await run('openclaw', ['doctor', '--fix'], { cwd: projectDir, env: openclawProjectEnv(projectDir) }).catch((err) => sendLog(`[plugin] doctor --fix skipped: ${err.message}`));
|
|
3430
|
-
sendLog(`[plugin] Installing ${
|
|
3427
|
+
sendLog(`[plugin] Installing ${installSpec}...`);
|
|
3431
3428
|
|
|
3432
3429
|
let installSuccess = true;
|
|
3433
|
-
await run('openclaw',
|
|
3430
|
+
await run('openclaw', installArgs, {
|
|
3434
3431
|
cwd: projectDir,
|
|
3435
3432
|
env: openclawProjectEnv(projectDir),
|
|
3436
3433
|
resolveOnPattern: /Installed plugin:/
|
|
@@ -3614,7 +3611,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
|
|
|
3614
3611
|
// THIS agent's workspace (not the global skills.entries flag, which would leak across bots).
|
|
3615
3612
|
const imageGenOn = isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg);
|
|
3616
3613
|
const webSearchOn = isEnabled(['duckduckgo']);
|
|
3617
|
-
const stickerMentionOn = isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg);
|
|
3618
3614
|
const learningMemoryOn = isSkillFolderExists(projectDir, aid, 'learning-memory', cfg);
|
|
3619
3615
|
const aliases = {
|
|
3620
3616
|
browser: ['openclaw-browser-automation', 'browser-automation'],
|
|
@@ -3629,7 +3625,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
|
|
|
3629
3625
|
'skill:cron': cronOn,
|
|
3630
3626
|
'skill:image-gen': imageGenOn,
|
|
3631
3627
|
'skill:web-search': webSearchOn,
|
|
3632
|
-
'skill:sticker-mention': stickerMentionOn,
|
|
3633
3628
|
'skill:learning-memory': learningMemoryOn,
|
|
3634
3629
|
'plugin:openclaw-browser-automation': isEnabled(aliases.browser),
|
|
3635
3630
|
'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
|
|
@@ -3645,7 +3640,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
|
|
|
3645
3640
|
extensionDirExists(aliases) || isInstalledByRecord(aliases);
|
|
3646
3641
|
const installed = {
|
|
3647
3642
|
'skill:image-gen': isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg),
|
|
3648
|
-
'skill:sticker-mention': isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg),
|
|
3649
3643
|
'skill:learning-memory': isSkillFolderExists(projectDir, aid, 'learning-memory', cfg),
|
|
3650
3644
|
'plugin:openclaw-browser-automation': isActuallyInstalled(aliases.browser),
|
|
3651
3645
|
'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
|
|
@@ -3659,7 +3653,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
|
|
|
3659
3653
|
};
|
|
3660
3654
|
const versions = {
|
|
3661
3655
|
'skill:image-gen': await getInstalledSkillVersion(projectDir, aid, 'infographic-generator', cfg),
|
|
3662
|
-
'skill:sticker-mention': await getInstalledSkillVersion(projectDir, aid, 'zalo-sticker-mention', cfg),
|
|
3663
3656
|
'skill:learning-memory': await getInstalledSkillVersion(projectDir, aid, 'learning-memory', cfg),
|
|
3664
3657
|
'plugin:openclaw-browser-automation': await getInstalledPluginVersion(projectDir, aliases.browser),
|
|
3665
3658
|
'plugin:openclaw-zalo-mod': await getInstalledPluginVersion(projectDir, aliases.zalo),
|
|
@@ -3680,7 +3673,8 @@ async function getFeatureFlags(projectDir, agentId = '') {
|
|
|
3680
3673
|
fillVer('plugin:openclaw-fb-messenger', aliases.fbMessenger);
|
|
3681
3674
|
fillVer('plugin:memory-tencentdb', aliases.tencentMemory);
|
|
3682
3675
|
}
|
|
3683
|
-
|
|
3676
|
+
const zaloBackend = fresh.channels?.['zalo-connect']?.enabled ? 'zalo-connect' : '';
|
|
3677
|
+
return { flags, installed, versions, zaloBackend };
|
|
3684
3678
|
}
|
|
3685
3679
|
|
|
3686
3680
|
async function serveStatic(req, res) {
|
|
@@ -3895,6 +3889,14 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3895
3889
|
await saveState(rootProjectDir);
|
|
3896
3890
|
sendLog(`✅ Bot created: ${result.agentId} (${result.channel})`);
|
|
3897
3891
|
if (result.warning) sendLog(`⚠️ ${result.warning}`);
|
|
3892
|
+
// A first Zalo bot changes the project's docker infra needs (the entrypoint must
|
|
3893
|
+
// install the pinned zalo-connect plugin BEFORE the gateway starts). Force-resync so
|
|
3894
|
+
// the recreate below ships the zaloBackend-aware entrypoint — without this, the
|
|
3895
|
+
// login flow has to install mid-boot and restart the container, which can
|
|
3896
|
+
// interrupt OpenClaw's first-run migrations and wedge its state lease.
|
|
3897
|
+
if (result.channel === 'zalo-personal') {
|
|
3898
|
+
await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[sync] infra resync failed: ${err.message}`));
|
|
3899
|
+
}
|
|
3898
3900
|
await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
|
|
3899
3901
|
|
|
3900
3902
|
if (result.channel === 'telegram') {
|
|
@@ -3919,11 +3921,11 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3919
3921
|
// Delay login start to let the recreated container fully boot gateway + plugins
|
|
3920
3922
|
setTimeout(async () => {
|
|
3921
3923
|
try {
|
|
3922
|
-
const login = await
|
|
3923
|
-
if (login?.qrDataUrl) sendLog(`[
|
|
3924
|
-
if (login?.message) sendLog(`[
|
|
3924
|
+
const login = await startZaloLogin(projectDir, result.agentId);
|
|
3925
|
+
if (login?.qrDataUrl) sendLog(`[zalo-connect:qr] ${login.qrDataUrl}`);
|
|
3926
|
+
if (login?.message) sendLog(`[zalo-connect] ${login.message}`);
|
|
3925
3927
|
} catch (err) {
|
|
3926
|
-
sendLog(`[
|
|
3928
|
+
sendLog(`[zalo-connect] Login failed: ${err.message}`);
|
|
3927
3929
|
}
|
|
3928
3930
|
}, 5000);
|
|
3929
3931
|
}
|
|
@@ -3944,15 +3946,22 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3944
3946
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
3945
3947
|
setImmediate(async () => {
|
|
3946
3948
|
try {
|
|
3947
|
-
const login = await
|
|
3948
|
-
if (login?.qrDataUrl) sendLog(`[
|
|
3949
|
-
if (login?.message) sendLog(`[
|
|
3949
|
+
const login = await startZaloLogin(projectDir, agentId);
|
|
3950
|
+
if (login?.qrDataUrl) sendLog(`[zalo-connect:qr] ${login.qrDataUrl}`);
|
|
3951
|
+
if (login?.message) sendLog(`[zalo-connect] ${login.message}`);
|
|
3950
3952
|
} catch (err) {
|
|
3951
|
-
sendLog(`[
|
|
3953
|
+
sendLog(`[zalo-connect] Login failed: ${err.message}`);
|
|
3952
3954
|
}
|
|
3953
3955
|
});
|
|
3954
3956
|
return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
|
|
3955
3957
|
}
|
|
3958
|
+
if (url.pathname === '/api/zalo/login/cancel' && req.method === 'POST') {
|
|
3959
|
+
return json(res, cancelZaloLogin());
|
|
3960
|
+
}
|
|
3961
|
+
if (url.pathname === '/api/zalo/health' && req.method === 'GET') {
|
|
3962
|
+
const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
|
|
3963
|
+
return json(res, await getZaloHealth(projectDir));
|
|
3964
|
+
}
|
|
3956
3965
|
if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
|
|
3957
3966
|
const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
|
|
3958
3967
|
const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
|
|
@@ -4201,7 +4210,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
4201
4210
|
printRemoteAccessHint(port).catch(() => {});
|
|
4202
4211
|
}
|
|
4203
4212
|
|
|
4204
|
-
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig,
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4213
|
+
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot };
|