@chrysb/alphaclaw 0.9.5 → 0.9.7

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.
Files changed (32) hide show
  1. package/README.md +3 -2
  2. package/lib/public/assets/icons/whatsapp.svg +14 -0
  3. package/lib/public/css/tailwind.generated.css +1 -1
  4. package/lib/public/dist/app.bundle.js +2031 -1925
  5. package/lib/public/js/components/agents-tab/create-channel-modal.js +30 -13
  6. package/lib/public/js/components/channel-login-modal.js +82 -0
  7. package/lib/public/js/components/channels.js +347 -1
  8. package/lib/public/js/components/general/index.js +56 -8
  9. package/lib/public/js/components/modal-shell.js +18 -2
  10. package/lib/public/js/components/onboarding/welcome-pairing-step.js +11 -6
  11. package/lib/public/js/components/pairings.js +1 -1
  12. package/lib/public/js/components/welcome/index.js +0 -1
  13. package/lib/public/js/components/welcome/use-welcome.js +1 -1
  14. package/lib/public/js/lib/api.js +23 -0
  15. package/lib/public/js/lib/channel-provider-availability.js +1 -1
  16. package/lib/server/agents/channels.js +268 -4
  17. package/lib/server/agents/service.js +2 -0
  18. package/lib/server/agents/shared.js +133 -42
  19. package/lib/server/alphaclaw-version.js +7 -3
  20. package/lib/server/commands.js +5 -1
  21. package/lib/server/constants.js +7 -0
  22. package/lib/server/gateway.js +61 -18
  23. package/lib/server/onboarding/import/secret-detector.js +9 -0
  24. package/lib/server/onboarding/openclaw.js +39 -0
  25. package/lib/server/onboarding/validation.js +1 -1
  26. package/lib/server/routes/agents.js +39 -0
  27. package/lib/server/routes/pairings.js +2 -2
  28. package/lib/server/watchdog-notify.js +54 -13
  29. package/lib/server.js +1 -0
  30. package/package.json +2 -2
  31. package/patches/openclaw+2026.4.14.patch +13 -0
  32. package/patches/openclaw+2026.4.11.patch +0 -13
@@ -14,6 +14,7 @@ const kChannelEnvKeys = {
14
14
  telegram: "TELEGRAM_BOT_TOKEN",
15
15
  discord: "DISCORD_BOT_TOKEN",
16
16
  slack: "SLACK_BOT_TOKEN",
17
+ whatsapp: "WHATSAPP_OWNER_NUMBER",
17
18
  };
18
19
  const kChannelExtraEnvKeys = {
19
20
  slack: ["SLACK_APP_TOKEN"],
@@ -22,6 +23,7 @@ const kChannelTokenFields = {
22
23
  telegram: "botToken",
23
24
  discord: "token",
24
25
  slack: "botToken",
26
+ // WhatsApp uses owner number, not a bot token field
25
27
  };
26
28
  const kChannelExtraTokenFields = {
27
29
  slack: ["appToken"],
@@ -30,6 +32,13 @@ const kChannelLabels = {
30
32
  telegram: "Telegram",
31
33
  discord: "Discord",
32
34
  slack: "Slack",
35
+ whatsapp: "WhatsApp",
36
+ };
37
+ const kChannelProviderAliases = {
38
+ wa: "whatsapp",
39
+ "whats-app": "whatsapp",
40
+ whats_app: "whatsapp",
41
+ "whats app": "whatsapp",
33
42
  };
34
43
  const kMaskedChannelToken = "********";
35
44
 
@@ -39,6 +48,44 @@ const shellEscapeArg = (value) =>
39
48
  const resolveCredentialsDirPath = ({ OPENCLAW_DIR }) =>
40
49
  path.join(OPENCLAW_DIR, "credentials");
41
50
 
51
+ const resolveWhatsAppCredentialCandidatePaths = ({
52
+ OPENCLAW_DIR,
53
+ accountId,
54
+ }) => {
55
+ const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
56
+ const normalizedAccountId = normalizeChannelAccountId(accountId);
57
+ return [
58
+ path.join(credentialsDir, "whatsapp", normalizedAccountId, "creds.json"),
59
+ ...(normalizedAccountId === "default"
60
+ ? [path.join(credentialsDir, "creds.json")]
61
+ : []),
62
+ ];
63
+ };
64
+
65
+ const hasSavedWhatsAppCredentials = ({
66
+ fsImpl,
67
+ OPENCLAW_DIR,
68
+ accountId,
69
+ }) => {
70
+ const candidatePaths = resolveWhatsAppCredentialCandidatePaths({
71
+ OPENCLAW_DIR,
72
+ accountId,
73
+ });
74
+ const matches = candidatePaths.map((targetPath) => {
75
+ try {
76
+ const exists = !!String(fsImpl.readFileSync(targetPath, "utf8") || "").trim();
77
+ return { path: targetPath, exists };
78
+ } catch (error) {
79
+ return {
80
+ path: targetPath,
81
+ exists: false,
82
+ error: String(error?.message || error || "read failed"),
83
+ };
84
+ }
85
+ });
86
+ return matches.some((entry) => entry.exists);
87
+ };
88
+
42
89
  const resolveAgentWorkspacePath = ({ OPENCLAW_DIR, agentId }) =>
43
90
  path.join(
44
91
  OPENCLAW_DIR,
@@ -149,7 +196,7 @@ const normalizeChannelProvider = (value) => {
149
196
  .trim()
150
197
  .toLowerCase();
151
198
  if (!provider || !kChannelEnvKeys[provider]) {
152
- throw new Error("Unsupported channel provider");
199
+ throw new Error(`Unsupported channel provider "${provider}"`);
153
200
  }
154
201
  return provider;
155
202
  };
@@ -397,6 +444,26 @@ const resolveCredentialPairingAccountId = ({ channelId, fileName }) => {
397
444
  return normalizeChannelAccountId(rawAccountId);
398
445
  };
399
446
 
447
+ const hasImplicitWhatsAppSelfPairing = ({
448
+ fsImpl,
449
+ OPENCLAW_DIR,
450
+ channelId,
451
+ accountId,
452
+ accountConfig,
453
+ }) => {
454
+ if (String(channelId || "").trim() !== "whatsapp") return false;
455
+ if (!accountConfig || typeof accountConfig !== "object") return false;
456
+ if (accountConfig.selfChatMode === false) return false;
457
+ if (String(accountConfig.dmPolicy || "").trim().toLowerCase() === "disabled") {
458
+ return false;
459
+ }
460
+ return hasSavedWhatsAppCredentials({
461
+ fsImpl,
462
+ OPENCLAW_DIR,
463
+ accountId,
464
+ });
465
+ };
466
+
400
467
  const readPairedCountsByAccount = ({
401
468
  fsImpl,
402
469
  OPENCLAW_DIR,
@@ -412,30 +479,33 @@ const readPairedCountsByAccount = ({
412
479
  );
413
480
  const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
414
481
  try {
415
- const files = fsImpl
416
- .readdirSync(credentialsDir)
417
- .filter(
418
- (fileName) =>
419
- String(fileName || "").startsWith(
420
- `${String(channelId || "").trim()}-`,
421
- ) && String(fileName || "").endsWith("-allowFrom.json"),
422
- );
423
- for (const fileName of files) {
424
- const accountId = resolveCredentialPairingAccountId({
425
- channelId,
426
- fileName,
427
- });
428
- if (!accountId || !counts.has(accountId)) continue;
429
- const filePath = path.join(credentialsDir, fileName);
430
- const parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
431
- const pairedCount = Array.isArray(parsed?.allowFrom)
432
- ? parsed.allowFrom.length
433
- : 0;
434
- counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);
482
+ if (String(channelId || "").trim() !== "whatsapp") {
483
+ const files = fsImpl
484
+ .readdirSync(credentialsDir)
485
+ .filter(
486
+ (fileName) =>
487
+ String(fileName || "").startsWith(
488
+ `${String(channelId || "").trim()}-`,
489
+ ) && String(fileName || "").endsWith("-allowFrom.json"),
490
+ );
491
+ for (const fileName of files) {
492
+ const accountId = resolveCredentialPairingAccountId({
493
+ channelId,
494
+ fileName,
495
+ });
496
+ if (!accountId || !counts.has(accountId)) continue;
497
+ const filePath = path.join(credentialsDir, fileName);
498
+ const parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
499
+ const pairedCount = Array.isArray(parsed?.allowFrom)
500
+ ? parsed.allowFrom.length
501
+ : 0;
502
+ counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);
503
+ }
435
504
  }
436
505
  } catch {}
437
506
 
438
507
  for (const accountId of counts.keys()) {
508
+ if (String(channelId || "").trim() === "whatsapp") continue;
439
509
  const accountConfig =
440
510
  accountId === "default" &&
441
511
  !(config.accounts && typeof config.accounts === "object")
@@ -449,6 +519,26 @@ const readPairedCountsByAccount = ({
449
519
  );
450
520
  }
451
521
 
522
+ for (const accountId of counts.keys()) {
523
+ if (Number(counts.get(accountId) || 0) > 0) continue;
524
+ const accountConfig =
525
+ accountId === "default" &&
526
+ !(config.accounts && typeof config.accounts === "object")
527
+ ? config
528
+ : config.accounts?.[accountId] || {};
529
+ if (
530
+ hasImplicitWhatsAppSelfPairing({
531
+ fsImpl,
532
+ OPENCLAW_DIR,
533
+ channelId,
534
+ accountId,
535
+ accountConfig,
536
+ })
537
+ ) {
538
+ counts.set(accountId, 1);
539
+ }
540
+ }
541
+
452
542
  return counts;
453
543
  };
454
544
 
@@ -509,27 +599,26 @@ const listConfiguredChannelAccounts = ({ fsImpl, OPENCLAW_DIR, cfg }) => {
509
599
  });
510
600
  return {
511
601
  channel: String(channelId || "").trim(),
512
- accounts: normalizedAccountIds
513
- .map((accountId) => {
514
- const accountConfig =
515
- accountId === "default" && accountIds.length === 0
516
- ? config
517
- : accountsConfig?.[accountId] || {};
518
- return {
519
- id: accountId,
520
- name: String(accountConfig?.name || "").trim(),
521
- envKey: deriveChannelEnvKey({ provider: channelId, accountId }),
522
- boundAgentId:
523
- boundAccountMap.get(
524
- `${String(channelId || "").trim()}:${accountId}`,
525
- ) || "",
526
- paired: Number(pairedCounts.get(accountId) || 0),
527
- status:
528
- Number(pairedCounts.get(accountId) || 0) > 0
529
- ? "paired"
530
- : "configured",
531
- };
532
- }),
602
+ accounts: normalizedAccountIds.map((accountId) => {
603
+ const accountConfig =
604
+ accountId === "default" && accountIds.length === 0
605
+ ? config
606
+ : accountsConfig?.[accountId] || {};
607
+ return {
608
+ id: accountId,
609
+ name: String(accountConfig?.name || "").trim(),
610
+ envKey: deriveChannelEnvKey({ provider: channelId, accountId }),
611
+ boundAgentId:
612
+ boundAccountMap.get(
613
+ `${String(channelId || "").trim()}:${accountId}`,
614
+ ) || "",
615
+ paired: Number(pairedCounts.get(accountId) || 0),
616
+ status:
617
+ Number(pairedCounts.get(accountId) || 0) > 0
618
+ ? "paired"
619
+ : "configured",
620
+ };
621
+ }),
533
622
  };
534
623
  })
535
624
  .filter(Boolean);
@@ -673,6 +762,8 @@ module.exports = {
673
762
  kMaskedChannelToken,
674
763
  shellEscapeArg,
675
764
  resolveCredentialsDirPath,
765
+ resolveWhatsAppCredentialCandidatePaths,
766
+ hasSavedWhatsAppCredentials,
676
767
  resolveAgentWorkspacePath,
677
768
  loadConfig,
678
769
  saveConfig,
@@ -96,7 +96,7 @@ const extractTemplateVersions = (pkg) => ({
96
96
  latestOpenclawVersion: normalizeOpenclawVersion(pkg?.dependencies?.openclaw),
97
97
  });
98
98
 
99
- const fetchLatestVersionFromRegistry = async ({ fetchImpl }) => {
99
+ const fetchLatestVersionFromRegistry = async ({ fetchImpl, version = null }) => {
100
100
  if (typeof fetchImpl !== "function") {
101
101
  throw new Error("Fetch is not available for AlphaClaw version checks");
102
102
  }
@@ -109,7 +109,8 @@ const fetchLatestVersionFromRegistry = async ({ fetchImpl }) => {
109
109
  response,
110
110
  "Failed to fetch latest AlphaClaw version",
111
111
  );
112
- const latestVersion = normalizeVersion(data?.["dist-tags"]?.latest);
112
+ const latestVersion =
113
+ normalizeVersion(version) || normalizeVersion(data?.["dist-tags"]?.latest);
113
114
  const latestOpenclawVersion = latestVersion
114
115
  ? normalizeOpenclawVersion(
115
116
  data?.versions?.[latestVersion]?.dependencies?.openclaw,
@@ -143,7 +144,10 @@ const fetchTemplatePackageVersions = async ({
143
144
  const versions = extractTemplateVersions(data);
144
145
  if (!versions.latestOpenclawVersion && versions.latestVersion) {
145
146
  try {
146
- const registry = await fetchLatestVersionFromRegistry({ fetchImpl });
147
+ const registry = await fetchLatestVersionFromRegistry({
148
+ fetchImpl,
149
+ version: versions.latestVersion,
150
+ });
147
151
  versions.latestOpenclawVersion = registry.latestOpenclawVersion || null;
148
152
  } catch {}
149
153
  }
@@ -32,7 +32,10 @@ const createCommands = ({ gatewayEnv }) => {
32
32
  });
33
33
  });
34
34
 
35
- const clawCmd = (cmd, { quiet = false, timeoutMs = 15000 } = {}) =>
35
+ const clawCmd = (
36
+ cmd,
37
+ { quiet = false, timeoutMs = 15000, killSignal = "SIGTERM" } = {},
38
+ ) =>
36
39
  new Promise((resolve) => {
37
40
  if (!quiet) console.log(`[alphaclaw] Running: openclaw ${cmd}`);
38
41
  exec(
@@ -40,6 +43,7 @@ const createCommands = ({ gatewayEnv }) => {
40
43
  {
41
44
  env: gatewayEnv(),
42
45
  timeout: timeoutMs,
46
+ killSignal,
43
47
  },
44
48
  (err, stdout, stderr) => {
45
49
  const result = {
@@ -256,6 +256,12 @@ const kKnownVars = [
256
256
  group: "channels",
257
257
  hint: "From Basic Information → App-Level Tokens (xapp-...)",
258
258
  },
259
+ {
260
+ key: "WHATSAPP_OWNER_NUMBER",
261
+ label: "WhatsApp Owner Number",
262
+ group: "channels",
263
+ hint: "E.164 number, e.g. +15551234567",
264
+ },
259
265
  {
260
266
  key: "MISTRAL_API_KEY",
261
267
  label: "Mistral API Key",
@@ -358,6 +364,7 @@ const kChannelDefs = {
358
364
  telegram: { envKey: "TELEGRAM_BOT_TOKEN" },
359
365
  discord: { envKey: "DISCORD_BOT_TOKEN" },
360
366
  slack: { envKey: "SLACK_BOT_TOKEN", extraEnvKeys: ["SLACK_APP_TOKEN"] },
367
+ whatsapp: { envKey: "WHATSAPP_OWNER_NUMBER", sync: false },
361
368
  };
362
369
  const kProtectedBrowsePaths = new Set(
363
370
  Array.isArray(kBrowseFilePolicies?.protectedPaths)
@@ -371,6 +371,32 @@ const getChannelStatus = () => {
371
371
  );
372
372
  const credDir = `${OPENCLAW_DIR}/credentials`;
373
373
  const channels = {};
374
+ const hasImplicitWhatsAppSelfPairing = ({ accountId, accountConfig }) => {
375
+ if (!accountConfig || typeof accountConfig !== "object") return false;
376
+ if (accountConfig.selfChatMode === false) return false;
377
+ if (String(accountConfig.dmPolicy || "").trim().toLowerCase() === "disabled") {
378
+ return false;
379
+ }
380
+ const candidatePaths = [
381
+ `${credDir}/whatsapp/${accountId}/creds.json`,
382
+ ...(accountId === "default" ? [`${credDir}/creds.json`] : []),
383
+ ];
384
+ const matches = candidatePaths.map((targetPath) => {
385
+ try {
386
+ return {
387
+ path: targetPath,
388
+ exists: !!String(fs.readFileSync(targetPath, "utf8") || "").trim(),
389
+ };
390
+ } catch (error) {
391
+ return {
392
+ path: targetPath,
393
+ exists: false,
394
+ error: String(error?.message || error || "read failed"),
395
+ };
396
+ }
397
+ });
398
+ return matches.some((entry) => entry.exists);
399
+ };
374
400
 
375
401
  for (const ch of Object.keys(kChannelDefs)) {
376
402
  const channelConfig =
@@ -404,27 +430,30 @@ const getChannelStatus = () => {
404
430
  Array.from(configuredAccountIds).map((accountId) => [accountId, 0]),
405
431
  );
406
432
  try {
407
- const files = fs
408
- .readdirSync(credDir)
409
- .filter(
410
- (f) => f.startsWith(`${ch}-`) && f.endsWith("-allowFrom.json"),
411
- );
412
- for (const file of files) {
413
- const accountId = resolveCredentialPairingAccountId({
414
- channel: ch,
415
- fileName: file,
416
- });
417
- if (!accountId || !configuredAccountIds.has(accountId)) continue;
418
- const data = JSON.parse(
419
- fs.readFileSync(`${credDir}/${file}`, "utf8"),
420
- );
421
- const nextCount =
422
- Number(pairedByAccount.get(accountId) || 0)
423
- + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);
424
- pairedByAccount.set(accountId, nextCount);
433
+ if (ch !== "whatsapp") {
434
+ const files = fs
435
+ .readdirSync(credDir)
436
+ .filter(
437
+ (f) => f.startsWith(`${ch}-`) && f.endsWith("-allowFrom.json"),
438
+ );
439
+ for (const file of files) {
440
+ const accountId = resolveCredentialPairingAccountId({
441
+ channel: ch,
442
+ fileName: file,
443
+ });
444
+ if (!accountId || !configuredAccountIds.has(accountId)) continue;
445
+ const data = JSON.parse(
446
+ fs.readFileSync(`${credDir}/${file}`, "utf8"),
447
+ );
448
+ const nextCount =
449
+ Number(pairedByAccount.get(accountId) || 0)
450
+ + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);
451
+ pairedByAccount.set(accountId, nextCount);
452
+ }
425
453
  }
426
454
  } catch {}
427
455
  for (const [accountId, accountConfig] of accountEntries) {
456
+ if (ch === "whatsapp") continue;
428
457
  const inlineAllowFrom = accountConfig?.allowFrom;
429
458
  if (!Array.isArray(inlineAllowFrom)) continue;
430
459
  const normalizedAccountId = normalizeChannelAccountId(accountId);
@@ -432,6 +461,20 @@ const getChannelStatus = () => {
432
461
  Number(pairedByAccount.get(normalizedAccountId) || 0) + inlineAllowFrom.length;
433
462
  pairedByAccount.set(normalizedAccountId, nextCount);
434
463
  }
464
+ if (ch === "whatsapp") {
465
+ for (const [accountId, accountConfig] of accountEntries) {
466
+ const normalizedAccountId = normalizeChannelAccountId(accountId);
467
+ if (Number(pairedByAccount.get(normalizedAccountId) || 0) > 0) continue;
468
+ if (
469
+ hasImplicitWhatsAppSelfPairing({
470
+ accountId: normalizedAccountId,
471
+ accountConfig,
472
+ })
473
+ ) {
474
+ pairedByAccount.set(normalizedAccountId, 1);
475
+ }
476
+ }
477
+ }
435
478
  const accounts = Object.fromEntries(
436
479
  Array.from(pairedByAccount.entries()).map(([accountId, paired]) => [
437
480
  accountId,
@@ -311,6 +311,15 @@ const extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => {
311
311
  if (channels.discord?.token && !isAlreadyEnvRef(channels.discord.token)) {
312
312
  preFill.DISCORD_BOT_TOKEN = channels.discord.token;
313
313
  }
314
+ const whatsAppAllowFrom = Array.isArray(channels.whatsapp?.allowFrom)
315
+ ? channels.whatsapp.allowFrom
316
+ : [];
317
+ const whatsAppOwner = whatsAppAllowFrom.find(
318
+ (v) => v && !isAlreadyEnvRef(String(v)),
319
+ );
320
+ if (whatsAppOwner) {
321
+ preFill.WHATSAPP_OWNER_NUMBER = String(whatsAppOwner);
322
+ }
314
323
 
315
324
  const braveKey = cfg.tools?.web?.search?.apiKey;
316
325
  if (braveKey && !isAlreadyEnvRef(braveKey)) {
@@ -198,6 +198,19 @@ const applyFreshOnboardingChannels = ({ cfg, varMap }) => {
198
198
  ensurePluginAllowed({ cfg, pluginKey: "slack" });
199
199
  console.log("[onboard] Slack configured");
200
200
  }
201
+ if (varMap.WHATSAPP_OWNER_NUMBER) {
202
+ cfg.channels.whatsapp = {
203
+ enabled: true,
204
+ allowFrom: [varMap.WHATSAPP_OWNER_NUMBER],
205
+ groupAllowFrom: [varMap.WHATSAPP_OWNER_NUMBER],
206
+ dmPolicy: "allowlist",
207
+ groupPolicy: "allowlist",
208
+ selfChatMode: true,
209
+ };
210
+ cfg.plugins.entries.whatsapp = { enabled: true };
211
+ ensurePluginAllowed({ cfg, pluginKey: "whatsapp" });
212
+ console.log("[onboard] WhatsApp configured");
213
+ }
201
214
  ensureUsageTrackerPluginEntry(cfg);
202
215
  };
203
216
 
@@ -280,6 +293,32 @@ const writeManagedImportOpenclawConfig = ({ fs, openclawDir, varMap }) => {
280
293
  ensurePluginAllowed({ cfg, pluginKey: "slack" });
281
294
  }
282
295
 
296
+ if (varMap.WHATSAPP_OWNER_NUMBER) {
297
+ const existingWhatsApp = cfg.channels.whatsapp || {};
298
+ const existingAllowFrom = Array.isArray(existingWhatsApp.allowFrom)
299
+ ? existingWhatsApp.allowFrom
300
+ : [];
301
+ const ownerRef = "${WHATSAPP_OWNER_NUMBER}";
302
+ cfg.channels.whatsapp = {
303
+ ...existingWhatsApp,
304
+ enabled: true,
305
+ allowFrom: existingAllowFrom.includes(ownerRef)
306
+ ? existingAllowFrom
307
+ : [...existingAllowFrom, ownerRef],
308
+ groupAllowFrom: existingAllowFrom.includes(ownerRef)
309
+ ? existingAllowFrom
310
+ : [...existingAllowFrom, ownerRef],
311
+ dmPolicy: "allowlist",
312
+ groupPolicy: "allowlist",
313
+ selfChatMode: true,
314
+ };
315
+ cfg.plugins.entries.whatsapp = {
316
+ ...(cfg.plugins.entries.whatsapp || {}),
317
+ enabled: true,
318
+ };
319
+ ensurePluginAllowed({ cfg, pluginKey: "whatsapp" });
320
+ }
321
+
283
322
  fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
284
323
  };
285
324
 
@@ -94,7 +94,7 @@ const validateOnboardingInput = ({ vars, modelKey, resolveModelProvider, hasCode
94
94
  return hasAnyAi;
95
95
  })();
96
96
  const hasGithub = !!(githubToken && githubRepoInput);
97
- const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN));
97
+ const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) || varMap.WHATSAPP_OWNER_NUMBER);
98
98
 
99
99
  if (!hasAi) {
100
100
  if (selectedProvider === "openai-codex") {
@@ -124,6 +124,45 @@ const registerAgentRoutes = ({
124
124
  }
125
125
  });
126
126
 
127
+ app.post("/api/channels/accounts/login", async (req, res) => {
128
+ try {
129
+ const body = req.body || {};
130
+ const result = await agentsService.runChannelAccountLogin({
131
+ provider: body.provider,
132
+ accountId: body.accountId,
133
+ });
134
+ return res.json({
135
+ ok: true,
136
+ completed: !!result?.ok,
137
+ stdout: String(result?.stdout || ""),
138
+ stderr: String(result?.stderr || ""),
139
+ code: result?.code ?? null,
140
+ });
141
+ } catch (error) {
142
+ const status = String(error.message || "").includes("only supported")
143
+ ? 400
144
+ : 500;
145
+ return res.status(status).json({ ok: false, error: error.message });
146
+ }
147
+ });
148
+
149
+ app.get("/api/channels/accounts/login-status", (req, res) => {
150
+ try {
151
+ const provider = String(req.query?.provider || "").trim();
152
+ const accountId = String(req.query?.accountId || "").trim() || "default";
153
+ const result = agentsService.getChannelAccountLoginStatus({
154
+ provider,
155
+ accountId,
156
+ });
157
+ return res.json({ ok: true, ...result });
158
+ } catch (error) {
159
+ const status = String(error.message || "").includes("only supported")
160
+ ? 400
161
+ : 500;
162
+ return res.status(status).json({ ok: false, error: error.message });
163
+ }
164
+ });
165
+
127
166
  app.delete("/api/channels/accounts", async (req, res) => {
128
167
  try {
129
168
  const body = req.body || {};
@@ -5,7 +5,7 @@ const { buildManagedPaths } = require("../internal-files-migration");
5
5
  const { parseJsonObjectFromNoisyOutput } = require("../utils/json");
6
6
  const { quoteShellArg } = require("../utils/shell");
7
7
 
8
- const kAllowedPairingChannels = new Set(["telegram", "discord", "slack"]);
8
+ const kAllowedPairingChannels = new Set(["telegram", "discord", "slack", "whatsapp"]);
9
9
  const kSafePairingArgPattern = /^[\w\-:.]+$/;
10
10
  const kDevicesListCliTimeoutMs = 5000;
11
11
  const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
@@ -112,7 +112,7 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
112
112
  }
113
113
 
114
114
  const pending = [];
115
- const channels = ["telegram", "discord", "slack"];
115
+ const channels = ["telegram", "discord", "slack", "whatsapp"];
116
116
 
117
117
  for (const ch of channels) {
118
118
  try {
@@ -2,8 +2,10 @@ const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { OPENCLAW_DIR } = require("./constants");
4
4
  const { createSlackApi } = require("./slack-api");
5
+ const { quoteShellArg } = require("./utils/shell");
5
6
 
6
7
  const kSlackBotEnvKey = "SLACK_BOT_TOKEN";
8
+ const kWhatsAppOwnerNumberEnvKey = "WHATSAPP_OWNER_NUMBER";
7
9
 
8
10
  const normalizeAccountId = (value) =>
9
11
  String(value || "").trim().toLowerCase() || "default";
@@ -106,6 +108,7 @@ const createWatchdogNotifier = ({
106
108
  telegramApi,
107
109
  discordApi,
108
110
  slackApi,
111
+ clawCmd = null,
109
112
  readEnvFile = () => [],
110
113
  createSlackApi: createSlackApiFactory = createSlackApi,
111
114
  fsImpl = fs,
@@ -116,7 +119,17 @@ const createWatchdogNotifier = ({
116
119
  telegram: { sent: 0, failed: 0, skipped: false, targets: 0 },
117
120
  discord: { sent: 0, failed: 0, skipped: false, targets: 0 },
118
121
  slack: { sent: 0, failed: 0, skipped: false, targets: 0 },
122
+ whatsapp: { sent: 0, failed: 0, skipped: false, targets: 0 },
119
123
  };
124
+ const envVars = typeof readEnvFile === "function" ? readEnvFile() : [];
125
+ const envMap = new Map(
126
+ (Array.isArray(envVars) ? envVars : [])
127
+ .map((entry) => [
128
+ String(entry?.key || "").trim(),
129
+ String(entry?.value || "").trim(),
130
+ ])
131
+ .filter(([key]) => key),
132
+ );
120
133
  const telegramTargets = getPairedIds({
121
134
  channel: "telegram",
122
135
  fsImpl,
@@ -174,17 +187,6 @@ const createWatchdogNotifier = ({
174
187
  summary.slack.skipped = true;
175
188
  } else {
176
189
  const eventType = opts.eventType || "info"; // crash, recovery, health, info
177
- const envVars = typeof readEnvFile === "function" ? readEnvFile() : [];
178
-
179
- const envMap = new Map(
180
- (Array.isArray(envVars) ? envVars : [])
181
- .map((entry) => [
182
- String(entry?.key || "").trim(),
183
- String(entry?.value || "").trim(),
184
- ])
185
- .filter(([key]) => key),
186
- );
187
-
188
190
  for (const [accountId, slackTargets] of slackTargetsByAccount.entries()) {
189
191
  if (!slackTargets.length) continue;
190
192
  const envKey = deriveSlackBotEnvKey(accountId);
@@ -261,8 +263,47 @@ const createWatchdogNotifier = ({
261
263
  }
262
264
  }
263
265
 
264
- const sent = summary.telegram.sent + summary.discord.sent + summary.slack.sent;
265
- const failed = summary.telegram.failed + summary.discord.failed + summary.slack.failed;
266
+ const whatsAppOwnerNumber = String(
267
+ envMap.get(kWhatsAppOwnerNumberEnvKey) ||
268
+ process.env[kWhatsAppOwnerNumberEnvKey] ||
269
+ "",
270
+ ).trim();
271
+ const whatsappTargets = whatsAppOwnerNumber ? [whatsAppOwnerNumber] : [];
272
+ summary.whatsapp.targets = whatsappTargets.length;
273
+ if (!clawCmd || whatsappTargets.length === 0) {
274
+ summary.whatsapp.skipped = true;
275
+ } else {
276
+ for (const target of whatsappTargets) {
277
+ try {
278
+ const result = await clawCmd(
279
+ `message send --channel whatsapp --target ${quoteShellArg(
280
+ String(target || "").trim(),
281
+ )} --message ${quoteShellArg(String(message || ""))}`,
282
+ { quiet: true, timeoutMs: 30000 },
283
+ );
284
+ if (!result?.ok) {
285
+ throw new Error(
286
+ String(result?.stderr || result?.stdout || "WhatsApp send failed"),
287
+ );
288
+ }
289
+ summary.whatsapp.sent += 1;
290
+ } catch (err) {
291
+ summary.whatsapp.failed += 1;
292
+ console.error(`[watchdog] whatsapp notification failed for ${target}: ${err.message}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ const sent =
298
+ summary.telegram.sent +
299
+ summary.discord.sent +
300
+ summary.slack.sent +
301
+ summary.whatsapp.sent;
302
+ const failed =
303
+ summary.telegram.failed +
304
+ summary.discord.failed +
305
+ summary.slack.failed +
306
+ summary.whatsapp.failed;
266
307
  return {
267
308
  ok: sent > 0,
268
309
  sent,