create-openclaw-bot 5.11.0 → 5.12.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,12 +1210,12 @@ 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)];
@@ -1446,22 +1436,13 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
1446
1436
  cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
1447
1437
  await appendEnvValue(projectDir, accountId === 'default' ? 'TELEGRAM_BOT_TOKEN' : `TELEGRAM_BOT_TOKEN_${agentId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`, token);
1448
1438
  } 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;
1439
+ const existingZaloConnectBindings = cfg.bindings.filter((b) => b.match?.channel === 'zalo-connect').length;
1440
+ if (existingZaloConnectBindings > 0) {
1441
+ 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
1442
  }
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 } });
1443
+ ensureZaloConnectChannel(cfg);
1444
+ accountId = 'default';
1445
+ cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId } });
1465
1446
  } else if (channel === 'fb-messenger') {
1466
1447
  // Token handling (user token → permanent Page token) is done by the fb-messenger
1467
1448
  // plugin itself; here we just persist whatever the user supplied plus the App ID
@@ -1502,7 +1483,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
1502
1483
  workspacePath: `/home/node/project/.openclaw/${workspaceDir}`,
1503
1484
  channel,
1504
1485
  hasZaloMod: channel === 'zalo-personal',
1505
- hasZaloSticker: false,
1486
+ zaloBackend: zaloBackendForConfig(cfg),
1506
1487
  hasScheduler,
1507
1488
  hasImageGen,
1508
1489
  });
@@ -1558,8 +1539,8 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
1558
1539
  cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
1559
1540
  cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
1560
1541
  } else if (channel === 'zalo-personal') {
1561
- ensureZaloUserChannel(cfg);
1562
- cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
1542
+ ensureZaloConnectChannel(cfg);
1543
+ cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId: 'default' } });
1563
1544
  } else if (channel === 'fb-messenger') {
1564
1545
  ensureFbMessengerChannel(cfg, String(body.pageId || '').trim(), String(body.appId || '').trim());
1565
1546
  cfg.bindings.push({ agentId, match: { channel: 'fb-messenger', accountId: 'default' } });
@@ -1624,7 +1605,7 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
1624
1605
  workspacePath: `/home/node/project/.openclaw/${workspaceDir}`,
1625
1606
  channel,
1626
1607
  hasZaloMod: channel === 'zalo-personal',
1627
- hasZaloSticker: false,
1608
+ zaloBackend: zaloBackendForConfig(cfg),
1628
1609
  hasScheduler,
1629
1610
  hasImageGen,
1630
1611
  });
@@ -1668,7 +1649,7 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
1668
1649
  return false;
1669
1650
  }
1670
1651
 
1671
- async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000) {
1652
+ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
1672
1653
  const started = Date.now();
1673
1654
  // Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
1674
1655
  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 +1663,232 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
1682
1663
  const status = String(out.stdout || '').trim();
1683
1664
  if (status === 'READY') {
1684
1665
  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')) {
1666
+ const output = ((pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '')).toLowerCase();
1667
+ if (channelKeywords.some((kw) => output.includes(kw))) {
1687
1668
  ready = true;
1688
1669
  break;
1689
1670
  }
1690
- if (attempts > 2) sendLog('[zalouser] Gateway healthy but zalouser not yet loaded (' + Math.round((Date.now() - started) / 1000) + 's)...');
1671
+ if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
1691
1672
  } else {
1692
- if (attempts > 2 && attempts % 3 === 0) sendLog('[zalouser] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
1673
+ if (attempts > 2 && attempts % 3 === 0) sendLog('[zalo-connect] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
1693
1674
  }
1694
1675
  } catch {}
1695
1676
  await new Promise((r) => setTimeout(r, 5000));
1696
1677
  }
1697
1678
  if (!ready) {
1698
- sendLog('[zalouser] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
1679
+ sendLog('[zalo-connect] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
1699
1680
  }
1700
1681
  return ready;
1701
1682
  }
1702
1683
 
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;
1713
- }
1714
- }
1715
- } catch (e) {
1716
- sendLog(`[zalouser] Warning: Failed to parse openclaw.json: ${e.message}`);
1717
- }
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) {}
1729
- }
1730
-
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
- }
1742
-
1684
+ async function startZaloLogin(projectDir, agentId = "") {
1685
+ const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
1686
+ if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
1687
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
1688
+ const binding = (cfg.bindings || []).find((b) =>
1689
+ (!agentId || b.agentId === agentId) && b.match?.channel === "zalo-connect"
1690
+ );
1691
+ return startZaloConnectLogin(projectDir, binding?.match?.accountId || "default");
1692
+ }
1693
+
1694
+ // ── OpenClaw Zalo Connect QR login (new projects) ────────────────────────────────────────────
1695
+ // zalo-connect prints the QR two ways: ASCII art on stdout and a PNG written to the
1696
+ // container's tmpdir, announced with "QR image saved at: /tmp/zalo-connect-qr-<id>.png".
1697
+ // We watch stdout for that line, read the PNG out of the container, and push it to
1698
+ // the UI modal as a data URL ([zalo-connect:qr] log tag). Reconnect NEVER reinstalls the
1699
+ // plugin install runs only when extensions/zalo-connect is absent, and always with the
1700
+ // pinned spec (never `latest`).
1701
+ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1743
1702
  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 {}
1755
- });
1756
1703
  return { message: 'Zalo login is already running. Keep this modal open...' };
1757
1704
  }
1758
- zaloLoginInFlight = true;
1759
- sendLog(`[zalouser] Preparing login for profile [${profile}]. QR will be generated for the UI modal.`);
1760
1705
  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');
1706
+ if (!existsSync(composeFile)) {
1707
+ throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
1782
1708
  }
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.');
1823
- }
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');
1709
+ zaloLoginInFlight = true;
1710
+ const botContainer = getBotContainerName(projectDir);
1711
+ sendLog(`[zalo-connect] Preparing QR login for account [${accountId}]...`);
1712
+ try {
1713
+ // NEVER poke the container while it is still booting: OpenClaw runs first-boot
1714
+ // migrations under a state lease, and a docker exec/restart mid-migration wedges
1715
+ // the lease and crash-loops the gateway. Wait for the container, then for the
1716
+ // gateway to report the zalo-connect channel (the entrypoint installs the pinned
1717
+ // plugin itself on first boot), and only fall back to an exec-install when the
1718
+ // gateway is up but the plugin is genuinely absent (projects created before the
1719
+ // backend-aware entrypoint existed).
1720
+ const containerUp = await waitForDockerContainer(botContainer, 90000);
1721
+ if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
1722
+ const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
1723
+ if (!gatewayReady) {
1724
+ 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' }));
1725
+ if (String(check.stdout || '').trim() === 'MISSING') {
1726
+ sendLog(`[zalo-connect] Plugin missing installing pinned ${ZALO_CONNECT_PLUGIN_SPEC}...`);
1727
+ const repo = String(ZALO_CONNECT_PLUGIN_SPEC).split('#')[0];
1728
+ const ref = String(ZALO_CONNECT_PLUGIN_SPEC).split('#')[1] || ZALO_CONNECT_VERSION;
1729
+ const installCmd = `tmp=/tmp/openclaw-plugin-zalo-connect-${ref}; rm -rf "$tmp"; git clone --depth 1 --branch "${ref}" "${repo}" "$tmp" && cd /home/node/project && openclaw plugins install "$tmp" 2>&1`;
1730
+ const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
1731
+ const instOut = `${inst.stdout}\n${inst.stderr}`;
1732
+ for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
1733
+ if (/installed plugin/i.test(instOut)) {
1734
+ // Gateway must reload to pick the plugin up — safe here: the gateway is past
1735
+ // its boot (we only reach this branch when it answered the exec above).
1736
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
1737
+ await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
1738
+ } else {
1739
+ 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.');
1740
+ }
1841
1741
  }
1842
1742
  }
1743
+ } catch (err) {
1744
+ zaloLoginInFlight = false;
1745
+ throw err;
1843
1746
  }
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);
1747
+
1748
+ sendLog('[zalo-connect] Generating Zalo QR. The image will appear automatically.');
1749
+ const loginCmd = `cd /home/node/project && openclaw channels login --channel zalo-connect --account ${accountId} --verbose`;
1750
+ let qrSent = false;
1751
+ let loginDone = false;
1752
+
1753
+ const pushQrFromContainer = async (pngPath) => {
1754
+ 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{}`;
1755
+ const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false }).catch(() => ({ stdout: '' }));
1756
+ const b64 = extractCompletePngBase64(out.stdout);
1757
+ if (b64.length > 100) {
1758
+ qrSent = true;
1759
+ sendLog(`[zalo-connect:qr] data:image/png;base64,${b64}`);
1760
+ sendLog('[zalo-connect] Scan this QR with the Zalo app.');
1858
1761
  }
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.');
1762
+ };
1763
+
1764
+ const isQrAsciiArt = (line) => /^[\s▀▄█▌▐░▒▓]+$/.test(line);
1765
+ const handleLine = (line) => {
1766
+ const qrFile = line.match(/QR image saved at:\s*(\S+\.png)/i);
1767
+ if (qrFile) {
1768
+ pushQrFromContainer(qrFile[1]).catch(() => {});
1769
+ return;
1770
+ }
1771
+ if (isQrAsciiArt(line)) return; // don't flood the UI modal with terminal QR art
1772
+ sendLog(`[zalo-connect] ${line}`);
1773
+ if (/login successful|login completed|logged in/i.test(line)) loginDone = true;
1774
+ };
1775
+ // Retry loop: right after a boot the gateway may still refuse `channels login`
1776
+ // ("container is restarting", "still preparing"), so give it a few spaced attempts.
1777
+ const MAX_ATTEMPTS = 3;
1778
+ const RETRY_DELAYS = [0, 10000, 20000];
1779
+ let attempt = 0;
1780
+ const runAttempt = () => {
1781
+ attempt++;
1782
+ if (attempt > 1) sendLog(`[zalo-connect] Retry ${attempt}/${MAX_ATTEMPTS}...`);
1783
+ const child = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
1784
+ zaloLoginChild = child;
1785
+ child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
1786
+ child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
1787
+ child.on('error', (err) => sendLog(`[zalo-connect] Login process failed: ${err.message}`));
1788
+ child.on('close', async (code) => {
1789
+ const wasCancelled = child.killed && zaloLoginChild === null;
1790
+ if (zaloLoginChild === child) zaloLoginChild = null;
1791
+ sendLog(`[zalo-connect] Login process exited ${code}`);
1792
+ if (loginDone) {
1793
+ sendLog(`[zalo-connect] Login saved. Restarting ${botContainer} so the Zalo channel connects...`);
1794
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalo-connect] Container restart failed: ${err.message}`));
1795
+ sendLog(`[zalo-connect] ${botContainer} restarted. Try sending a Zalo message now.`);
1796
+ zaloLoginInFlight = false;
1797
+ } else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
1798
+ const delay = RETRY_DELAYS[attempt] || 15000;
1799
+ sendLog(`[zalo-connect] QR chưa sẵn sàng — thử lại sau ${delay / 1000}s...`);
1800
+ setTimeout(runAttempt, delay);
1801
+ } else {
1802
+ if (!qrSent && !wasCancelled) sendLog('[zalo-connect] Login ended without a QR. Click "Đăng nhập Zalo" to retry.');
1803
+ zaloLoginInFlight = false;
1892
1804
  }
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
- };
1805
+ });
1806
+ };
1807
+ runAttempt();
1927
1808
 
1928
- runLoginAttempt();
1929
- return { message: 'Generating Zalo QR. The image will appear automatically.' };
1809
+ return { message: 'Generating Zalo QR. The image will appear automatically.' };
1810
+ }
1811
+
1812
+ // Cancel an in-flight Zalo QR login (modal closed). Kills the CLI login process so
1813
+ // no orphaned login keeps polling Zalo; safe to call when nothing is running.
1814
+ function cancelZaloLogin() {
1815
+ const child = zaloLoginChild;
1816
+ if (child) {
1817
+ try { child.kill('SIGTERM'); } catch {}
1818
+ zaloLoginChild = null;
1819
+ zaloLoginInFlight = false;
1820
+ sendLog('[zalo-connect] Login cancelled.');
1821
+ return { ok: true, cancelled: true };
1822
+ }
1823
+ return { ok: true, cancelled: false };
1824
+ }
1825
+
1826
+ // ── Zalo health snapshot for the dashboard card ─────────────────────────────────
1827
+ // One cheap, non-throwing aggregate: backend, pinned vs installed version, channel
1828
+ // status (parsed from `openclaw channels status`), QR/session presence, Zalo Mod
1829
+ // presence. Everything degrades to null/'unknown' instead of failing the request.
1830
+ async function getZaloHealth(projectDir) {
1831
+ const out = {
1832
+ backend: '',
1833
+ supportedVersion: ZALO_CONNECT_VERSION,
1834
+ installedVersion: null,
1835
+ containerRunning: false,
1836
+ channelStatus: 'unknown',
1837
+ channelStatusLine: '',
1838
+ sessionSaved: false,
1839
+ zaloModInstalled: false,
1840
+ accountId: 'default',
1841
+ };
1842
+ if (!projectDir) return out;
1843
+ let cfg = null;
1844
+ try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
1845
+ if (!cfg) return out;
1846
+ if (cfg.channels?.['zalo-connect']?.enabled) out.backend = 'zalo-connect';
1847
+ if (!out.backend) return out;
1848
+ const binding = (cfg.bindings || []).find((b) => b.match?.channel === out.backend);
1849
+ if (binding?.match?.accountId) out.accountId = binding.match.accountId;
1850
+ out.zaloModInstalled = !!(cfg.plugins?.entries?.['zalo-mod'] || cfg.plugins?.entries?.['openclaw-zalo-mod'])
1851
+ || existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-mod'));
1852
+
1853
+ // Installed zalo-connect version — extensions/ is bind-mounted on macOS/Linux; fall back
1854
+ // to reading inside the container (Windows keeps extensions on a named volume).
1855
+ const botContainer = getBotContainerName(projectDir);
1856
+ if (out.backend === 'zalo-connect') {
1857
+ const manifestHost = join(projectDir, '.openclaw', 'extensions', 'zalo-connect', 'openclaw.plugin.json');
1858
+ try {
1859
+ out.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
1860
+ } catch {
1861
+ try {
1862
+ 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 });
1863
+ out.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
1864
+ } catch {}
1865
+ }
1866
+ // QR session/credentials saved? (zalo-connect stores its credential file under the state dir)
1867
+ try {
1868
+ const credRoot = join(projectDir, '.openclaw', 'credentials');
1869
+ const names = existsSync(credRoot) ? await fsp.readdir(credRoot) : [];
1870
+ out.sessionSaved = names.some((n) => n.toLowerCase().includes('zalo-connect'));
1871
+ } catch {}
1930
1872
  }
1931
1873
 
1932
- throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
1874
+ try {
1875
+ const r = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', botContainer], { shell: false, timeout: 8000 });
1876
+ out.containerRunning = String(r.stdout || '').trim() === 'true';
1877
+ } catch {}
1878
+ if (out.containerRunning) {
1879
+ try {
1880
+ const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false, timeout: 20000 });
1881
+ const needle = out.backend === 'zalo-connect' ? 'zalo-connect' : 'zalo';
1882
+ const line = String(r.stdout || '').split(/\r?\n/).find((l) => l.toLowerCase().includes(needle)) || '';
1883
+ out.channelStatusLine = line.trim();
1884
+ if (/running|connected|ready|ok/i.test(line)) out.channelStatus = 'connected';
1885
+ else if (/stopped|disconnected|error|failed/i.test(line)) out.channelStatus = 'disconnected';
1886
+ else if (line) out.channelStatus = 'starting';
1887
+ } catch {}
1888
+ } else {
1889
+ out.channelStatus = 'container-stopped';
1890
+ }
1891
+ return out;
1933
1892
  }
1934
1893
 
1935
1894
  function getBotServiceName(projectDir) {
@@ -1991,12 +1950,12 @@ async function syncDockerInfra(projectDir, force = false) {
1991
1950
  const routerPort = state.routerPort || 20128;
1992
1951
  const osChoice = await resolveProjectHostOs(projectDir);
1993
1952
 
1994
- // Detect features from openclaw.json
1953
+ // Detect the single supported personal-Zalo backend from openclaw.json.
1995
1954
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1996
- let hasZalo = false;
1955
+ let zaloBackend = '';
1997
1956
  try {
1998
1957
  const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
1999
- hasZalo = !!cfg.channels?.zalouser?.enabled;
1958
+ if (cfg.channels?.['zalo-connect']?.enabled) zaloBackend = 'zalo-connect';
2000
1959
  } catch {}
2001
1960
 
2002
1961
  // Regenerate with detected settings
@@ -2012,8 +1971,8 @@ async function syncDockerInfra(projectDir, force = false) {
2012
1971
  singleComposeName: composeName,
2013
1972
  singleAppContainerName: botContainer,
2014
1973
  singleRouterContainerName: routerContainer,
1974
+ zaloBackend,
2015
1975
  runtimeCommandParts: [
2016
- hasZalo ? 'ensure_zalouser' : '',
2017
1976
  'while true; do sleep 5; openclaw devices approve --latest 2>/dev/null || true; done >/dev/null 2>&1 &',
2018
1977
  ].filter(Boolean),
2019
1978
  plainSingleExtraHosts: true,
@@ -2089,37 +2048,6 @@ async function recreateDockerBot(projectDir) {
2089
2048
  await run('docker', ['compose', '-f', composeFile, 'up', '-d', '--build', '--force-recreate', serviceName], { cwd: projectDir });
2090
2049
  await waitForDockerContainer(containerName);
2091
2050
 
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
2051
  // Container was rebuilt/recreated: runtime, versions and extension versions may all have changed.
2124
2052
  probeCacheClear();
2125
2053
  return true;
@@ -3050,7 +2978,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
3050
2978
  for (const a of cfg.agents.list) {
3051
2979
  const sf = await readWorkspaceText(projectDir, a, 'skills/cronjob/SKILL.md');
3052
2980
  await fsp.mkdir(dirname(sf.file), { recursive: true });
3053
- await fsp.writeFile(sf.file, buildCronjobSkillMd(true), 'utf8');
2981
+ await fsp.writeFile(sf.file, buildCronjobSkillMd(true, 'zalo-connect'), 'utf8');
3054
2982
  }
3055
2983
  } else {
3056
2984
  if (cfg.tools?.alsoAllow) cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:automation');
@@ -3072,47 +3000,22 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
3072
3000
  }
3073
3001
  }
3074
3002
 
3075
- // Folder-based per-bot skills (image-gen / sticker-mention / learning-memory). These load
3003
+ // Folder-based per-bot skills (image-gen / learning-memory). These load
3076
3004
  // from <workspace>/skills, so each bot's copy is independent. Toggling here affects ONLY the
3077
3005
  // active bot's workspace folder. The global skills.entries[id].enabled flag is kept true
3078
3006
  // 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' };
3007
+ if (kind === 'skill' && (id === 'image-gen' || id === 'learning-memory')) {
3008
+ const slugMap = { 'image-gen': 'infographic-generator', 'learning-memory': 'learning-memory' };
3081
3009
  const slug = slugMap[id];
3082
3010
  const rel = workspaceRelForAgent(agent, cfg, projectDir) || `workspace-${agent.id}`;
3083
3011
  const folder = join(projectDir, '.openclaw', rel, 'skills', slug);
3084
3012
  const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
3085
3013
 
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
3014
  let installedNow = false;
3109
3015
  if (enabled) {
3110
3016
  // Enable for THIS bot only: ensure its workspace has the skill folder.
3111
3017
  if (!existsSync(folder)) { await installFeature(projectDir, agent.id, 'skill', id); installedNow = true; }
3112
- await applyStickerPatch(false);
3113
3018
  } else {
3114
- // Disable for THIS bot only: restore any patch, then remove just this bot's folder.
3115
- await applyStickerPatch(true);
3116
3019
  await fsp.rm(folder, { recursive: true, force: true }).catch(() => {});
3117
3020
  }
3118
3021
 
@@ -3267,7 +3170,6 @@ async function installFeature(projectDir, agentId, kind, id) {
3267
3170
  if (kind === 'skill') {
3268
3171
  const skillSlugMap = {
3269
3172
  'image-gen': 'infographic-generator',
3270
- 'sticker-mention': 'zalo-sticker-mention',
3271
3173
  'learning-memory': 'learning-memory',
3272
3174
  };
3273
3175
  const slug = skillSlugMap[id] || id;
@@ -3614,7 +3516,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
3614
3516
  // THIS agent's workspace (not the global skills.entries flag, which would leak across bots).
3615
3517
  const imageGenOn = isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg);
3616
3518
  const webSearchOn = isEnabled(['duckduckgo']);
3617
- const stickerMentionOn = isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg);
3618
3519
  const learningMemoryOn = isSkillFolderExists(projectDir, aid, 'learning-memory', cfg);
3619
3520
  const aliases = {
3620
3521
  browser: ['openclaw-browser-automation', 'browser-automation'],
@@ -3629,7 +3530,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
3629
3530
  'skill:cron': cronOn,
3630
3531
  'skill:image-gen': imageGenOn,
3631
3532
  'skill:web-search': webSearchOn,
3632
- 'skill:sticker-mention': stickerMentionOn,
3633
3533
  'skill:learning-memory': learningMemoryOn,
3634
3534
  'plugin:openclaw-browser-automation': isEnabled(aliases.browser),
3635
3535
  'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
@@ -3645,7 +3545,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
3645
3545
  extensionDirExists(aliases) || isInstalledByRecord(aliases);
3646
3546
  const installed = {
3647
3547
  'skill:image-gen': isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg),
3648
- 'skill:sticker-mention': isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg),
3649
3548
  'skill:learning-memory': isSkillFolderExists(projectDir, aid, 'learning-memory', cfg),
3650
3549
  'plugin:openclaw-browser-automation': isActuallyInstalled(aliases.browser),
3651
3550
  'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
@@ -3659,7 +3558,6 @@ async function getFeatureFlags(projectDir, agentId = '') {
3659
3558
  };
3660
3559
  const versions = {
3661
3560
  'skill:image-gen': await getInstalledSkillVersion(projectDir, aid, 'infographic-generator', cfg),
3662
- 'skill:sticker-mention': await getInstalledSkillVersion(projectDir, aid, 'zalo-sticker-mention', cfg),
3663
3561
  'skill:learning-memory': await getInstalledSkillVersion(projectDir, aid, 'learning-memory', cfg),
3664
3562
  'plugin:openclaw-browser-automation': await getInstalledPluginVersion(projectDir, aliases.browser),
3665
3563
  'plugin:openclaw-zalo-mod': await getInstalledPluginVersion(projectDir, aliases.zalo),
@@ -3680,7 +3578,8 @@ async function getFeatureFlags(projectDir, agentId = '') {
3680
3578
  fillVer('plugin:openclaw-fb-messenger', aliases.fbMessenger);
3681
3579
  fillVer('plugin:memory-tencentdb', aliases.tencentMemory);
3682
3580
  }
3683
- return { flags, installed, versions };
3581
+ const zaloBackend = fresh.channels?.['zalo-connect']?.enabled ? 'zalo-connect' : '';
3582
+ return { flags, installed, versions, zaloBackend };
3684
3583
  }
3685
3584
 
3686
3585
  async function serveStatic(req, res) {
@@ -3895,6 +3794,14 @@ async function handler(req, res, rootProjectDir) {
3895
3794
  await saveState(rootProjectDir);
3896
3795
  sendLog(`✅ Bot created: ${result.agentId} (${result.channel})`);
3897
3796
  if (result.warning) sendLog(`⚠️ ${result.warning}`);
3797
+ // A first Zalo bot changes the project's docker infra needs (the entrypoint must
3798
+ // install the pinned zalo-connect plugin BEFORE the gateway starts). Force-resync so
3799
+ // the recreate below ships the zaloBackend-aware entrypoint — without this, the
3800
+ // login flow has to install mid-boot and restart the container, which can
3801
+ // interrupt OpenClaw's first-run migrations and wedge its state lease.
3802
+ if (result.channel === 'zalo-personal') {
3803
+ await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[sync] infra resync failed: ${err.message}`));
3804
+ }
3898
3805
  await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
3899
3806
 
3900
3807
  if (result.channel === 'telegram') {
@@ -3919,11 +3826,11 @@ async function handler(req, res, rootProjectDir) {
3919
3826
  // Delay login start to let the recreated container fully boot gateway + plugins
3920
3827
  setTimeout(async () => {
3921
3828
  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}`);
3829
+ const login = await startZaloLogin(projectDir, result.agentId);
3830
+ if (login?.qrDataUrl) sendLog(`[zalo-connect:qr] ${login.qrDataUrl}`);
3831
+ if (login?.message) sendLog(`[zalo-connect] ${login.message}`);
3925
3832
  } catch (err) {
3926
- sendLog(`[zalouser] Login failed: ${err.message}`);
3833
+ sendLog(`[zalo-connect] Login failed: ${err.message}`);
3927
3834
  }
3928
3835
  }, 5000);
3929
3836
  }
@@ -3944,15 +3851,22 @@ async function handler(req, res, rootProjectDir) {
3944
3851
  const projectDir = await resolveProjectDir(rootProjectDir, body);
3945
3852
  setImmediate(async () => {
3946
3853
  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}`);
3854
+ const login = await startZaloLogin(projectDir, agentId);
3855
+ if (login?.qrDataUrl) sendLog(`[zalo-connect:qr] ${login.qrDataUrl}`);
3856
+ if (login?.message) sendLog(`[zalo-connect] ${login.message}`);
3950
3857
  } catch (err) {
3951
- sendLog(`[zalouser] Login failed: ${err.message}`);
3858
+ sendLog(`[zalo-connect] Login failed: ${err.message}`);
3952
3859
  }
3953
3860
  });
3954
3861
  return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
3955
3862
  }
3863
+ if (url.pathname === '/api/zalo/login/cancel' && req.method === 'POST') {
3864
+ return json(res, cancelZaloLogin());
3865
+ }
3866
+ if (url.pathname === '/api/zalo/health' && req.method === 'GET') {
3867
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3868
+ return json(res, await getZaloHealth(projectDir));
3869
+ }
3956
3870
  if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
3957
3871
  const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
3958
3872
  const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
@@ -4201,7 +4115,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
4201
4115
  printRemoteAccessHint(port).catch(() => {});
4202
4116
  }
4203
4117
 
4204
- export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };
4205
-
4206
-
4207
-
4118
+ export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };