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.
@@ -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, buildStickerMentionSkillMd, buildStickerMentionJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
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 non-bundled plugins like zalouser, zalo-mod
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 ensureZaloUserChannel(cfg) {
972
- cfg.channels.zalouser = cfg.channels.zalouser || {
973
- enabled: true,
974
- defaultAccount: 'default',
975
- accounts: {
976
- default: {
977
- enabled: true,
978
- profile: 'default',
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.zalouser = cfg.plugins.entries.zalouser || { enabled: true };
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('zalouser')) cfg.plugins.allow.push('zalouser');
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 === 'zalouser') return 'zalo-personal';
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 === 'zalouser' ? 'zalo-personal' : b.match?.channel === 'zalo' ? 'zalo-bot' : 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?.zalouser?.enabled) enabled.push('zalo-personal');
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
- ensureZaloUserChannel(cfg);
1450
- const zu = cfg.channels.zalouser;
1451
- zu.accounts = zu.accounts || {};
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
- // First Zalo bot → the default account; each subsequent bot gets its own account
1459
- // (keyed by agentId) with a matching login profile — mirrors the Telegram multi-account flow.
1460
- const existingZaloBindings = cfg.bindings.filter((b) => b.match?.channel === 'zalouser').length;
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
- hasZaloSticker: false,
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
- ensureZaloUserChannel(cfg);
1562
- cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
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
- hasZaloSticker: false,
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 (output.toLowerCase().includes('zalouser') || output.toLowerCase().includes('zalo personal')) {
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('[zalouser] Gateway healthy but zalouser not yet loaded (' + Math.round((Date.now() - started) / 1000) + 's)...');
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('[zalouser] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
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('[zalouser] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
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 startZaloUserLogin(projectDir, mode = state.mode, agentId = '') {
1704
- let profile = 'default';
1705
- if (agentId) {
1706
- try {
1707
- const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1708
- if (existsSync(cfgPath)) {
1709
- const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
1710
- const binding = (cfg.bindings || []).find(b => b.agentId === agentId && b.match?.channel === 'zalouser');
1711
- if (binding?.match?.accountId) {
1712
- profile = binding.match.accountId;
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
- } else if (state.activeBotId) {
1719
- try {
1720
- const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
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
- const qrPaths = [
1732
- `/tmp/openclaw/openclaw-zalouser-qr-${profile}.png`,
1733
- `/tmp/openclaw-1000/openclaw-zalouser-qr-${profile}.png`
1734
- ];
1735
- if (profile === 'default') {
1736
- qrPaths.push(
1737
- '/tmp/openclaw/openclaw-zalouser-qr.png',
1738
- '/tmp/openclaw-1000/openclaw-zalouser-qr.png',
1739
- '/tmp/openclaw/openclaw-zalouser-qr-default.png'
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
- if (zaloLoginInFlight) {
1744
- setImmediate(async () => {
1745
- try {
1746
- const botContainer = getBotContainerName(projectDir);
1747
- 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{}}`;
1748
- const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
1749
- const b64 = extractCompletePngBase64(out.stdout);
1750
- if (b64.length > 100) {
1751
- sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
1752
- sendLog('[zalouser] Found running login session. Displaying current QR code.');
1753
- }
1754
- } catch {}
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
- return { message: 'Zalo login is already running. Keep this modal open...' };
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
- zaloLoginInFlight = true;
1759
- sendLog(`[zalouser] Preparing login for profile [${profile}]. QR will be generated for the UI modal.`);
1760
- const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
1761
- if ((mode === 'docker' || existsSync(composeFile)) && existsSync(composeFile)) {
1762
- const botContainer = getBotContainerName(projectDir);
1763
- // Verify if zalouser is properly registered in installs.json with channels array.
1764
- // npm install --prefix misses this, which causes error:not configured.
1765
- const checkRegistryScript = `
1766
- const fs = require('fs');
1767
- try {
1768
- // zalouser may live under npm/ (docker install) OR extensions/ (migrated/native). Treat
1769
- // either as present so we never re-download it on top of an existing copy (that creates a
1770
- // duplicate plugin id and breaks the shared ZCA API map).
1771
- const distNpm = '/home/node/project/.openclaw/npm/node_modules/@openclaw/zalouser/dist/index.js';
1772
- const distExt = '/home/node/project/.openclaw/extensions/zalouser/dist/index.js';
1773
- const inst = '/home/node/project/.openclaw/plugins/installs.json';
1774
- if (!fs.existsSync(distNpm) && !fs.existsSync(distExt)) { console.log('MISSING'); process.exit(0); }
1775
- if (!fs.existsSync(inst)) { console.log('MISSING_CHANNELS'); process.exit(0); }
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
- } catch(e) { console.log('MISSING'); }
1784
- `;
1785
- const checkInstall = await runCapture('docker', ['exec', botContainer, 'node', '-e', checkRegistryScript], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'MISSING' }));
1786
- const status = String(checkInstall.stdout || '').trim();
1787
- if (status !== 'OK') {
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
- }catch(e){}
1825
- try{
1826
- console.log('Patching zalouser stability settings...');
1827
- cp.execSync('ZALO_JS=$(find "/home/node/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'});
1828
- }catch(e){}
1829
- try{
1830
- const ep = '/home/node/project/docker/openclaw/entrypoint.sh';
1831
- if (fs.existsSync(ep)) {
1832
- let content = fs.readFileSync(ep, 'utf8');
1833
- if (!content.includes('zalo-monitor')) {
1834
- 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";
1835
- // Insert before 'openclaw gateway run'
1836
- const target = 'openclaw gateway run';
1837
- if (content.includes(target)) {
1838
- content = content.replace(target, monitor + target);
1839
- fs.writeFileSync(ep, content, 'utf8');
1840
- console.log('Added auto-restart monitor to entrypoint.sh');
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
- }catch(e){}
1845
- `;
1846
- const install = await runCapture('docker', ['exec', botContainer, 'node', '-e', fixScript], { cwd: projectDir, shell: false });
1847
- for (const line of `${install.stdout}\n${install.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
1848
- // Restart the gateway to load the new installs.json channels array so `openclaw channels login` works
1849
- await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
1850
- // Wait for gateway to fully reload zalouser plugin after restart (~20s for plugin load + buffer)
1851
- sendLog('[zalouser] Waiting for gateway to load zalouser plugin...');
1852
- await waitForGatewayZaloReady(botContainer, projectDir);
1853
- sendLog('[zalouser] Gateway ready with zalouser plugin.');
1854
- } else {
1855
- sendLog('[zalouser] Plugin already properly installed with channels array skipping install.');
1856
- // Even when plugin is installed, gateway may still be booting (e.g. after recreateDockerBot)
1857
- await waitForGatewayZaloReady(botContainer, projectDir);
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
- // Clean old credentials & QR files inside container
1861
- const credFile = profile === 'default' ? 'credentials.json' : `credentials-${profile}.json`;
1862
- const credPath = `/home/node/project/.openclaw/credentials/zalouser/${credFile}`;
1863
- await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `rm -f ${credPath} ${qrPaths.join(' ')}`], { cwd: projectDir, shell: false }).catch(() => {});
1864
-
1865
- sendLog('[zalouser] Generating Zalo QR. The image will appear automatically.');
1866
- const loginCmd = `cd /home/node/project && openclaw channels login --channel zalouser --account ${profile} --verbose`;
1867
-
1868
- // Retry-based login: the zalouser plugin may need time to connect to Zalo servers.
1869
- // The CLI often exits with "Still preparing QR" on the first attempt.
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
- }, 1000);
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 features from openclaw.json
2037
+ // Detect the single supported personal-Zalo backend from openclaw.json.
1995
2038
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1996
- let hasZalo = false;
2039
+ let zaloBackend = '';
1997
2040
  try {
1998
2041
  const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
1999
- hasZalo = !!cfg.channels?.zalouser?.enabled;
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
- const env = buildEnvFileContent({ ...common, apiKey: '', botToken: '' });
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
- await recreateDockerBot(projectDir).catch(() => {});
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 / sticker-mention / learning-memory). These load
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 === 'sticker-mention' || id === 'learning-memory')) {
3080
- const slugMap = { 'image-gen': 'infographic-generator', 'sticker-mention': 'zalo-sticker-mention', 'learning-memory': 'learning-memory' };
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 ${pluginInstallSpec(id)} inside container ${botContainer}...`);
3333
+ sendLog(`[plugin] Installing/updating ${installSpec} inside container ${botContainer}...`);
3337
3334
 
3338
- const cmd = `cd /home/node/project && openclaw plugins install ${pluginInstallSpec(id)} --force`;
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 ${pluginInstallSpec(id)}...`);
3427
+ sendLog(`[plugin] Installing ${installSpec}...`);
3431
3428
 
3432
3429
  let installSuccess = true;
3433
- await run('openclaw', ['plugins', 'install', pluginInstallSpec(id), '--force'], {
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
- return { flags, installed, versions };
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 startZaloUserLogin(projectDir, state.mode, result.agentId);
3923
- if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
3924
- if (login?.message) sendLog(`[zalouser] ${login.message}`);
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(`[zalouser] Login failed: ${err.message}`);
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 startZaloUserLogin(projectDir, state.mode, agentId);
3948
- if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
3949
- if (login?.message) sendLog(`[zalouser] ${login.message}`);
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(`[zalouser] Login failed: ${err.message}`);
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, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };
4205
-
4206
-
4207
-
4213
+ export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot };