@chrysb/alphaclaw 0.9.6 → 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 (30) 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 +1 -1
@@ -18,6 +18,7 @@ const kChannelEnvKeys = {
18
18
  telegram: "TELEGRAM_BOT_TOKEN",
19
19
  discord: "DISCORD_BOT_TOKEN",
20
20
  slack: "SLACK_BOT_TOKEN",
21
+ whatsapp: "WHATSAPP_OWNER_NUMBER",
21
22
  };
22
23
 
23
24
  const kChannelExtraEnvKeys = {
@@ -229,8 +230,10 @@ export const CreateChannelModal = ({
229
230
  }
230
231
  setName(providerLabel);
231
232
  }, [provider, providerHasAccounts, nameEditedManually, isEditMode]);
233
+ const normalizedProvider = String(provider || "").trim();
232
234
  const isSingleAccountProvider = isSingleAccountChannelProvider(provider);
233
- const needsAppToken = String(provider || "").trim() === "slack";
235
+ const needsAppToken = normalizedProvider === "slack";
236
+ const isWhatsApp = normalizedProvider === "whatsapp";
234
237
 
235
238
  const accountId = useMemo(() => {
236
239
  if (isEditMode) {
@@ -419,20 +422,34 @@ export const CreateChannelModal = ({
419
422
  </label>
420
423
 
421
424
  <label class="block space-y-1">
422
- <span class="text-xs text-fg-muted">
423
- ${needsAppToken ? "Bot Token" : "Token"}
425
+ <span class="text-xs text-gray-400">
426
+ ${isWhatsApp ? "Owner Number" : needsAppToken ? "Bot Token" : "Token"}
424
427
  </span>
425
- <${SecretInput}
426
- value=${token}
427
- onInput=${(event) => setToken(event.target.value)}
428
- placeholder=${token ? "" : "Paste bot token"}
429
- loading=${loadingToken}
430
- isSecret=${true}
431
- inputClass="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
432
- />
428
+ ${isWhatsApp
429
+ ? html`
430
+ <input
431
+ type="text"
432
+ value=${token}
433
+ onInput=${(event) => setToken(event.target.value)}
434
+ placeholder="+15551234567"
435
+ class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
436
+ />
437
+ `
438
+ : html`
439
+ <${SecretInput}
440
+ value=${token}
441
+ onInput=${(event) => setToken(event.target.value)}
442
+ placeholder=${token ? "" : "Paste bot token"}
443
+ loading=${loadingToken}
444
+ isSecret=${true}
445
+ inputClass="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
446
+ />
447
+ `}
433
448
  <p class="text-xs text-fg-muted">
434
- Saved behind the scenes as
435
- <code class="font-mono text-fg-muted ml-1">${envKey || "CHANNEL_TOKEN"}</code>.
449
+ ${isWhatsApp
450
+ ? "E.164 format phone number used for allowlist pairing."
451
+ : html`Saved behind the scenes as
452
+ <code class="font-mono text-fg-muted ml-1">${envKey || "CHANNEL_TOKEN"}</code>.`}
436
453
  </p>
437
454
  </label>
438
455
 
@@ -0,0 +1,82 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "./action-button.js";
4
+ import { CloseIcon } from "./icons.js";
5
+ import { ModalShell } from "./modal-shell.js";
6
+ import { PageHeader } from "./page-header.js";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ export const ChannelLoginModal = ({
11
+ visible = false,
12
+ loading = false,
13
+ title = "Link Channel",
14
+ output = "",
15
+ error = "",
16
+ runDisabled = false,
17
+ runLabel = "Generate QR",
18
+ runLoadingLabel = "Running...",
19
+ closeLabel = "Close",
20
+ onRun = async () => {},
21
+ onClose = () => {},
22
+ }) => {
23
+ if (!visible) return null;
24
+ const hasOutput = !!String(output || "").trim();
25
+ const hasError = !!String(error || "").trim();
26
+ const displayOutput = hasOutput
27
+ ? String(output)
28
+ : hasError
29
+ ? String(error)
30
+ : "No output yet. Generate QR to start login.";
31
+ return html`
32
+ <${ModalShell}
33
+ visible=${visible}
34
+ onClose=${onClose}
35
+ panelClassName="bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4"
36
+ >
37
+ <${PageHeader}
38
+ title=${title}
39
+ actions=${html`
40
+ <button
41
+ type="button"
42
+ onclick=${onClose}
43
+ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
44
+ aria-label="Close modal"
45
+ >
46
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
47
+ </button>
48
+ `}
49
+ />
50
+ <div class="space-y-3">
51
+ <p class="text-xs text-gray-500">
52
+ Click "Generate QR" to run channel login and capture terminal output.
53
+ </p>
54
+ <textarea
55
+ readonly
56
+ wrap="off"
57
+ value=${displayOutput}
58
+ class="w-full h-[440px] max-h-[70vh] text-[11px] leading-[1.1] font-mono text-gray-300 bg-black/30 border border-border rounded-lg p-3 outline-none resize-y overflow-auto"
59
+ />
60
+ </div>
61
+ <div class="flex justify-end gap-2 pt-1">
62
+ <${ActionButton}
63
+ onClick=${onClose}
64
+ disabled=${loading}
65
+ loading=${false}
66
+ tone="secondary"
67
+ size="sm"
68
+ idleLabel=${closeLabel}
69
+ />
70
+ <${ActionButton}
71
+ onClick=${onRun}
72
+ disabled=${loading || runDisabled}
73
+ loading=${loading}
74
+ tone="primary"
75
+ size="sm"
76
+ idleLabel=${runLabel}
77
+ loadingLabel=${runLoadingLabel}
78
+ />
79
+ </div>
80
+ </${ModalShell}>
81
+ `;
82
+ };
@@ -8,14 +8,19 @@ import {
8
8
  import htm from "htm";
9
9
  import { AddChannelMenu } from "./add-channel-menu.js";
10
10
  import { ChannelAccountStatusBadge } from "./channel-account-status-badge.js";
11
+ import { ChannelLoginModal } from "./channel-login-modal.js";
11
12
  import { ConfirmDialog } from "./confirm-dialog.js";
12
13
  import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
13
14
  import {
14
15
  deleteChannelAccount,
15
16
  fetchChannelAccounts,
17
+ fetchChannelAccountLoginStatus,
18
+ fetchRestartStatus,
19
+ runChannelAccountLogin,
16
20
  updateChannelAccount,
17
21
  } from "../lib/api.js";
18
22
  import { useCachedFetch } from "../hooks/use-cached-fetch.js";
23
+ import { usePolling } from "../hooks/usePolling.js";
19
24
  import {
20
25
  isImplicitDefaultAccount,
21
26
  resolveChannelAccountLabel,
@@ -27,11 +32,12 @@ import { showToast } from "./toast.js";
27
32
 
28
33
  const html = htm.bind(h);
29
34
 
30
- const ALL_CHANNELS = ["telegram", "discord", "slack"];
35
+ const ALL_CHANNELS = ["telegram", "discord", "slack", "whatsapp"];
31
36
  const kChannelMeta = {
32
37
  telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg" },
33
38
  discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg" },
34
39
  slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg" },
40
+ whatsapp: { label: "WhatsApp", iconSrc: "/assets/icons/whatsapp.svg" },
35
41
  };
36
42
 
37
43
  const getChannelMeta = (channelId = "") => {
@@ -49,6 +55,48 @@ const getChannelMeta = (channelId = "") => {
49
55
  const announceRestartRequired = () =>
50
56
  window.dispatchEvent(new CustomEvent("alphaclaw:restart-required"));
51
57
 
58
+ const appendTerminalOutput = (previousOutput = "", nextChunk = "") =>
59
+ [String(previousOutput || "").trim(), String(nextChunk || "").trim()]
60
+ .filter(Boolean)
61
+ .join("\n\n");
62
+
63
+ const cloneLoginModalState = (state = {}) => ({
64
+ loginAccount: state.loginAccount || null,
65
+ loginOutput: String(state.loginOutput || ""),
66
+ loginError: String(state.loginError || ""),
67
+ loginRunning: !!state.loginRunning,
68
+ loginMonitoring: !!state.loginMonitoring,
69
+ loginCompleted: !!state.loginCompleted,
70
+ loginLinked: !!state.loginLinked,
71
+ loginRestartingGateway: !!state.loginRestartingGateway,
72
+ loginRestartedGateway: !!state.loginRestartedGateway,
73
+ });
74
+
75
+ let kPreservedChannelLoginModalState = null;
76
+
77
+ const clearChannelLoginModalState = ({
78
+ setLoginAccount,
79
+ setLoginOutput,
80
+ setLoginError,
81
+ setLoginRunning,
82
+ setLoginMonitoring,
83
+ setLoginCompleted,
84
+ setLoginLinked,
85
+ setLoginRestartingGateway,
86
+ setLoginRestartedGateway,
87
+ }) => {
88
+ kPreservedChannelLoginModalState = null;
89
+ setLoginAccount(null);
90
+ setLoginOutput("");
91
+ setLoginError("");
92
+ setLoginRunning(false);
93
+ setLoginMonitoring(false);
94
+ setLoginCompleted(false);
95
+ setLoginLinked(false);
96
+ setLoginRestartingGateway(false);
97
+ setLoginRestartedGateway(false);
98
+ };
99
+
52
100
  export const ChannelsCard = ({
53
101
  title = "Channels",
54
102
  items = [],
@@ -140,7 +188,9 @@ export const Channels = ({
140
188
  agents = [],
141
189
  onNavigate = () => {},
142
190
  onRefreshStatuses = () => {},
191
+ onRestartGateway = async () => ({ ok: false }),
143
192
  }) => {
193
+ const preservedLoginState = cloneLoginModalState(kPreservedChannelLoginModalState || {});
144
194
  const [saving, setSaving] = useState(false);
145
195
  const [createLoadingLabel, setCreateLoadingLabel] = useState("Creating...");
146
196
  const [menuOpenId, setMenuOpenId] = useState("");
@@ -156,6 +206,20 @@ export const Channels = ({
156
206
  const channelAccounts = Array.isArray(channelAccountsPayload?.channels)
157
207
  ? channelAccountsPayload.channels
158
208
  : [];
209
+ const [loginAccount, setLoginAccount] = useState(preservedLoginState.loginAccount);
210
+ const [loginOutput, setLoginOutput] = useState(preservedLoginState.loginOutput);
211
+ const [loginError, setLoginError] = useState(preservedLoginState.loginError);
212
+ const [loginRunning, setLoginRunning] = useState(preservedLoginState.loginRunning);
213
+ const [loginMonitoring, setLoginMonitoring] = useState(preservedLoginState.loginMonitoring);
214
+ const [loginCompleted, setLoginCompleted] = useState(preservedLoginState.loginCompleted);
215
+ const [loginLinked, setLoginLinked] = useState(preservedLoginState.loginLinked);
216
+ const [loginRestartingGateway, setLoginRestartingGateway] = useState(
217
+ preservedLoginState.loginRestartingGateway,
218
+ );
219
+ const [loginRestartedGateway, setLoginRestartedGateway] = useState(
220
+ preservedLoginState.loginRestartedGateway,
221
+ );
222
+ const [loginRestartStatusChecked, setLoginRestartStatusChecked] = useState(false);
159
223
 
160
224
  const loadChannelAccounts = useCallback(async () => {
161
225
  try {
@@ -163,6 +227,62 @@ export const Channels = ({
163
227
  } catch {}
164
228
  }, [refreshChannelAccounts]);
165
229
 
230
+ const loginStatusPoll = usePolling(
231
+ () =>
232
+ fetchChannelAccountLoginStatus({
233
+ provider: loginAccount?.provider,
234
+ accountId: loginAccount?.id,
235
+ }),
236
+ 1000,
237
+ {
238
+ enabled:
239
+ !!loginAccount &&
240
+ !!loginMonitoring &&
241
+ String(loginAccount?.provider || "").trim() === "whatsapp",
242
+ },
243
+ );
244
+ const restartStatusPoll = usePolling(fetchRestartStatus, 2000, {
245
+ enabled: !!loginAccount && !!loginRestartingGateway,
246
+ });
247
+
248
+ const appendLoginOutput = useCallback((nextChunk = "") => {
249
+ setLoginOutput((currentOutput) => appendTerminalOutput(currentOutput, nextChunk));
250
+ }, []);
251
+
252
+ useEffect(() => {
253
+ const nextState = cloneLoginModalState({
254
+ loginAccount,
255
+ loginOutput,
256
+ loginError,
257
+ loginRunning,
258
+ loginMonitoring,
259
+ loginCompleted,
260
+ loginLinked,
261
+ loginRestartingGateway,
262
+ loginRestartedGateway,
263
+ });
264
+ const hasActiveLoginState =
265
+ !!nextState.loginAccount ||
266
+ !!nextState.loginOutput ||
267
+ !!nextState.loginError ||
268
+ !!nextState.loginRunning ||
269
+ !!nextState.loginMonitoring ||
270
+ !!nextState.loginCompleted ||
271
+ !!nextState.loginLinked ||
272
+ !!nextState.loginRestartingGateway ||
273
+ !!nextState.loginRestartedGateway;
274
+ kPreservedChannelLoginModalState = hasActiveLoginState ? nextState : null;
275
+ }, [
276
+ loginAccount,
277
+ loginCompleted,
278
+ loginError,
279
+ loginLinked,
280
+ loginMonitoring,
281
+ loginOutput,
282
+ loginRestartedGateway,
283
+ loginRestartingGateway,
284
+ loginRunning,
285
+ ]);
166
286
 
167
287
  const configuredChannelMap = useMemo(
168
288
  () =>
@@ -192,6 +312,51 @@ export const Channels = ({
192
312
  );
193
313
  const showAgentBadge = agents.length > 0;
194
314
 
315
+ useEffect(() => {
316
+ const handleOpenWhatsAppQr = () => {
317
+ const configuredWhatsApp = channelAccounts.find(
318
+ (entry) => String(entry?.channel || "").trim() === "whatsapp",
319
+ );
320
+ const account = Array.isArray(configuredWhatsApp?.accounts)
321
+ ? configuredWhatsApp.accounts[0]
322
+ : null;
323
+ if (!account) return;
324
+ const accountId = String(account?.id || "").trim() || "default";
325
+ const boundAgentId = String(account?.boundAgentId || "").trim();
326
+ const ownerAgentId =
327
+ boundAgentId ||
328
+ (isImplicitDefaultAccount({ accountId, boundAgentId })
329
+ ? defaultAgentId
330
+ : "");
331
+ const accountData = {
332
+ id: accountId,
333
+ provider: "whatsapp",
334
+ name: resolveChannelAccountLabel({
335
+ channelId: "whatsapp",
336
+ account,
337
+ providerLabel: getChannelMeta("whatsapp").label || "WhatsApp",
338
+ }),
339
+ ownerAgentId,
340
+ envKey: String(account?.envKey || "").trim(),
341
+ token: String(account?.token || "").trim(),
342
+ };
343
+ setLoginAccount(accountData);
344
+ setLoginOutput("");
345
+ setLoginError("");
346
+ setLoginRunning(false);
347
+ setLoginMonitoring(false);
348
+ setLoginCompleted(false);
349
+ setLoginLinked(false);
350
+ setLoginRestartingGateway(false);
351
+ setLoginRestartedGateway(false);
352
+ setLoginRestartStatusChecked(false);
353
+ };
354
+ window.addEventListener("alphaclaw:open-whatsapp-qr", handleOpenWhatsAppQr);
355
+ return () => {
356
+ window.removeEventListener("alphaclaw:open-whatsapp-qr", handleOpenWhatsAppQr);
357
+ };
358
+ }, [channelAccounts, defaultAgentId]);
359
+
195
360
  const handleUpdateChannel = async (payload) => {
196
361
  setSaving(true);
197
362
  try {
@@ -259,6 +424,134 @@ export const Channels = ({
259
424
  setSaving(false);
260
425
  }
261
426
  };
427
+ const handleRunChannelLogin = async () => {
428
+ if (!loginAccount) return;
429
+ setLoginRunning(true);
430
+ setLoginMonitoring(true);
431
+ setLoginCompleted(false);
432
+ setLoginLinked(false);
433
+ setLoginRestartingGateway(false);
434
+ setLoginRestartedGateway(false);
435
+ setLoginRestartStatusChecked(false);
436
+ setLoginError("");
437
+ setLoginOutput("");
438
+ try {
439
+ const result = await runChannelAccountLogin({
440
+ provider: loginAccount.provider,
441
+ accountId: loginAccount.id,
442
+ });
443
+ const combinedOutput = appendTerminalOutput(result?.stdout || "", result?.stderr || "");
444
+ setLoginOutput(combinedOutput || "No terminal output captured.");
445
+ setLoginCompleted(!!result?.completed);
446
+ if (result?.completed) {
447
+ await loginStatusPoll.refresh();
448
+ }
449
+ } catch (error) {
450
+ setLoginError(String(error?.message || "Could not start channel login"));
451
+ setLoginMonitoring(false);
452
+ } finally {
453
+ setLoginRunning(false);
454
+ }
455
+ };
456
+
457
+ useEffect(() => {
458
+ if (!loginAccount || !loginMonitoring || loginLinked || loginRestartingGateway) {
459
+ return;
460
+ }
461
+ if (!loginStatusPoll.data?.linked) return;
462
+
463
+ let cancelled = false;
464
+ setLoginLinked(true);
465
+ setLoginError("");
466
+ appendLoginOutput("✅ Saved WhatsApp credentials detected.");
467
+
468
+ (async () => {
469
+ setLoginRestartingGateway(true);
470
+ setLoginRestartStatusChecked(false);
471
+ appendLoginOutput("Restarting the gateway so the new WhatsApp session comes online...");
472
+ try {
473
+ const restartResult = await onRestartGateway();
474
+ if (restartResult && restartResult.ok === false) {
475
+ throw new Error(restartResult.error || "Could not restart gateway");
476
+ }
477
+ if (cancelled) return;
478
+ appendLoginOutput("✅ Gateway restart triggered. Waiting for it to come back online...");
479
+ await restartStatusPoll.refresh();
480
+ if (cancelled) return;
481
+ setLoginRestartStatusChecked(true);
482
+ } catch (error) {
483
+ if (cancelled) return;
484
+ setLoginError(String(error?.message || "Could not restart gateway"));
485
+ appendLoginOutput(
486
+ "WhatsApp linked, but the gateway restart failed. You may need to restart it manually.",
487
+ );
488
+ setLoginRestartStatusChecked(false);
489
+ setLoginRestartingGateway(false);
490
+ }
491
+ })();
492
+
493
+ return () => {
494
+ cancelled = true;
495
+ };
496
+ }, [
497
+ appendLoginOutput,
498
+ loadChannelAccounts,
499
+ loginAccount,
500
+ loginLinked,
501
+ loginMonitoring,
502
+ loginRestartingGateway,
503
+ loginStatusPoll.data?.linked,
504
+ onRefreshStatuses,
505
+ onRestartGateway,
506
+ restartStatusPoll.refresh,
507
+ ]);
508
+
509
+ useEffect(() => {
510
+ if (!loginAccount || !loginRestartingGateway) return;
511
+ if (!loginRestartStatusChecked) return;
512
+ const restartInProgress = !!restartStatusPoll.data?.restartInProgress;
513
+ const gatewayRunning = restartStatusPoll.data?.gatewayRunning !== false;
514
+ if (restartInProgress || !gatewayRunning) return;
515
+
516
+ let cancelled = false;
517
+
518
+ (async () => {
519
+ setLoginRestartedGateway(true);
520
+ setLoginMonitoring(false);
521
+ appendLoginOutput("✅ Gateway restart complete.");
522
+ showToast("Channel linked", "success");
523
+ await Promise.all([
524
+ loadChannelAccounts(),
525
+ Promise.resolve(onRefreshStatuses?.()),
526
+ ]);
527
+ if (cancelled) return;
528
+ clearChannelLoginModalState({
529
+ setLoginAccount,
530
+ setLoginOutput,
531
+ setLoginError,
532
+ setLoginRunning,
533
+ setLoginMonitoring,
534
+ setLoginCompleted,
535
+ setLoginLinked,
536
+ setLoginRestartingGateway,
537
+ setLoginRestartedGateway,
538
+ });
539
+ })();
540
+
541
+ return () => {
542
+ cancelled = true;
543
+ };
544
+ }, [
545
+ appendLoginOutput,
546
+ loadChannelAccounts,
547
+ loginAccount,
548
+ loginRestartStatusChecked,
549
+ loginRestartingGateway,
550
+ onRefreshStatuses,
551
+ restartStatusPoll.data?.gatewayRunning,
552
+ restartStatusPoll.data?.restartInProgress,
553
+ ]);
554
+
262
555
  const openCreateChannelModal = (provider) => {
263
556
  setMenuOpenId("");
264
557
  setEditingAccount({
@@ -396,6 +689,27 @@ export const Channels = ({
396
689
  >
397
690
  Edit
398
691
  </${OverflowMenuItem}>
692
+ ${channelId === "whatsapp"
693
+ ? html`
694
+ <${OverflowMenuItem}
695
+ onClick=${() => {
696
+ setMenuOpenId("");
697
+ setLoginAccount(accountData);
698
+ setLoginOutput("");
699
+ setLoginError("");
700
+ setLoginRunning(false);
701
+ setLoginMonitoring(false);
702
+ setLoginCompleted(false);
703
+ setLoginLinked(false);
704
+ setLoginRestartingGateway(false);
705
+ setLoginRestartedGateway(false);
706
+ setLoginRestartStatusChecked(false);
707
+ }}
708
+ >
709
+ Link WhatsApp (QR)
710
+ </${OverflowMenuItem}>
711
+ `
712
+ : null}
399
713
  <${OverflowMenuItem}
400
714
  className="text-status-error hover:text-status-error"
401
715
  onClick=${() => {
@@ -518,6 +832,38 @@ export const Channels = ({
518
832
  setDeletingAccount(null);
519
833
  }}
520
834
  />
835
+ <${ChannelLoginModal}
836
+ visible=${!!loginAccount}
837
+ loading=${loginRunning || loginRestartingGateway}
838
+ title=${`Link ${String(loginAccount?.name || "WhatsApp").trim()} via QR`}
839
+ output=${loginOutput}
840
+ error=${loginError}
841
+ onRun=${handleRunChannelLogin}
842
+ onClose=${() => {
843
+ if (loginRunning || loginRestartingGateway) return;
844
+ clearChannelLoginModalState({
845
+ setLoginAccount,
846
+ setLoginOutput,
847
+ setLoginError,
848
+ setLoginRunning,
849
+ setLoginMonitoring,
850
+ setLoginCompleted,
851
+ setLoginLinked,
852
+ setLoginRestartingGateway,
853
+ setLoginRestartedGateway,
854
+ });
855
+ }}
856
+ runDisabled=${loginRunning || loginRestartingGateway || loginRestartedGateway}
857
+ runLabel=${loginLinked
858
+ ? loginRestartingGateway
859
+ ? "Restarting..."
860
+ : loginRestartedGateway
861
+ ? "Linked"
862
+ : "Awaiting restart..."
863
+ : "Generate QR"}
864
+ runLoadingLabel=${loginRestartingGateway ? "Restarting..." : "Running..."}
865
+ closeLabel=${loginRestartedGateway ? "Done" : "Close"}
866
+ />
521
867
  </div>
522
868
  `;
523
869
  };
@@ -5,6 +5,7 @@ import { Channels } from "../channels.js";
5
5
  import { ChannelOperationsPanel } from "../channel-operations-panel.js";
6
6
  import { Pairings } from "../pairings.js";
7
7
  import { DevicePairings } from "../device-pairings.js";
8
+ import { ActionButton } from "../action-button.js";
8
9
  import { Google } from "../google/index.js";
9
10
  import { Features } from "../features.js";
10
11
  import { GeneralDoctorWarning } from "../doctor/general-warning.js";
@@ -14,6 +15,11 @@ import { useGeneralTab } from "./use-general-tab.js";
14
15
 
15
16
  const html = htm.bind(h);
16
17
 
18
+ const openWhatsAppQrModal = () => {
19
+ if (typeof window === "undefined") return;
20
+ window.dispatchEvent(new CustomEvent("alphaclaw:open-whatsapp-qr"));
21
+ };
22
+
17
23
  export const GeneralTab = ({
18
24
  statusData = null,
19
25
  watchdogData = null,
@@ -39,6 +45,23 @@ export const GeneralTab = ({
39
45
  isActive,
40
46
  restartSignal,
41
47
  });
48
+ const whatsappStatus = state.channels?.whatsapp || null;
49
+ const whatsappAccounts =
50
+ whatsappStatus?.accounts && typeof whatsappStatus.accounts === "object"
51
+ ? whatsappStatus.accounts
52
+ : {};
53
+ const hasWhatsAppAwaitingPairing =
54
+ Object.keys(whatsappAccounts).length > 0
55
+ ? Object.values(whatsappAccounts).some(
56
+ (account) => account && account.status !== "paired",
57
+ )
58
+ : String(whatsappStatus?.status || "").trim() === "configured";
59
+ const showWhatsAppPairingCard =
60
+ state.hasUnpaired &&
61
+ !state.pairingStatusRefreshing &&
62
+ Array.isArray(state.pending) &&
63
+ state.pending.length === 0 &&
64
+ hasWhatsAppAwaitingPairing;
42
65
 
43
66
  return html`
44
67
  <div class="space-y-4">
@@ -64,17 +87,42 @@ export const GeneralTab = ({
64
87
  agents=${agents}
65
88
  onNavigate=${onNavigate}
66
89
  onRefreshStatuses=${onRefreshStatuses}
90
+ onRestartGateway=${onRestartGateway}
67
91
  />
68
92
  `}
69
93
  pairingsSection=${html`
70
- <${Pairings}
71
- pending=${state.pending}
72
- channels=${state.channels}
73
- visible=${state.hasUnpaired}
74
- statusRefreshing=${state.pairingStatusRefreshing}
75
- onApprove=${actions.handleApprove}
76
- onReject=${actions.handleReject}
77
- />
94
+ ${showWhatsAppPairingCard
95
+ ? html`
96
+ <div class="bg-surface border border-border rounded-xl p-4">
97
+ <h2 class="card-label mb-3">Pending Pairings</h2>
98
+ <div class="text-center py-4 space-y-3">
99
+ <img
100
+ src="/assets/icons/whatsapp.svg"
101
+ alt=""
102
+ class="w-10 h-10 mx-auto"
103
+ aria-hidden="true"
104
+ />
105
+ <p class="text-body text-sm font-medium">WhatsApp needs to be linked</p>
106
+ <p class="text-fg-dim text-xs">Scan the QR code to finish pairing this channel.</p>
107
+ <${ActionButton}
108
+ onClick=${openWhatsAppQrModal}
109
+ tone="primary"
110
+ size="sm"
111
+ idleLabel="Open QR Code"
112
+ />
113
+ </div>
114
+ </div>
115
+ `
116
+ : html`
117
+ <${Pairings}
118
+ pending=${state.pending}
119
+ channels=${state.channels}
120
+ visible=${state.hasUnpaired}
121
+ statusRefreshing=${state.pairingStatusRefreshing}
122
+ onApprove=${actions.handleApprove}
123
+ onReject=${actions.handleReject}
124
+ />
125
+ `}
78
126
  `}
79
127
  />
80
128
  <${Features} onSwitchTab=${onSwitchTab} />