@nastechai/agent 0.16.0 → 0.17.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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,772 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
2
+ import {
3
+ AlertTriangle,
4
+ Check,
5
+ CheckCircle2,
6
+ ExternalLink,
7
+ PlugZap,
8
+ QrCode,
9
+ Radio,
10
+ RotateCw,
11
+ Save,
12
+ Settings2,
13
+ WifiOff,
14
+ X,
15
+ } from "lucide-react";
16
+ import * as QRCode from "qrcode";
17
+ import { Badge } from "@nastechai/ui/ui/components/badge";
18
+ import { Button } from "@nastechai/ui/ui/components/button";
19
+ import { Card, CardContent } from "@nastechai/ui/ui/components/card";
20
+ import { Input } from "@nastechai/ui/ui/components/input";
21
+ import { Label } from "@nastechai/ui/ui/components/label";
22
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
23
+ import { Switch } from "@nastechai/ui/ui/components/switch";
24
+ import { Toast } from "@nastechai/ui/ui/components/toast";
25
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
26
+ import { api } from "@/lib/api";
27
+ import type {
28
+ MessagingPlatform,
29
+ MessagingPlatformEnvVar,
30
+ MessagingPlatformUpdate,
31
+ TelegramOnboardingStartResponse,
32
+ } from "@/lib/api";
33
+ import { useModalBehavior } from "@/hooks/useModalBehavior";
34
+ import { usePageHeader } from "@/contexts/usePageHeader";
35
+ import { cn, themedBody } from "@/lib/utils";
36
+
37
+ // State → badge mapping. The backend emits a small, fixed vocabulary plus
38
+ // whatever the live gateway runtime reports (connected/disconnected/fatal).
39
+ const STATE_BADGE: Record<
40
+ string,
41
+ { tone: "success" | "warning" | "destructive" | "secondary" | "outline"; label: string }
42
+ > = {
43
+ connected: { tone: "success", label: "Connected" },
44
+ pending_restart: { tone: "warning", label: "Restart to apply" },
45
+ gateway_stopped: { tone: "warning", label: "Gateway stopped" },
46
+ disconnected: { tone: "warning", label: "Disconnected" },
47
+ not_configured: { tone: "outline", label: "Not configured" },
48
+ disabled: { tone: "secondary", label: "Disabled" },
49
+ fatal: { tone: "destructive", label: "Error" },
50
+ };
51
+
52
+ function stateBadge(state: string) {
53
+ return STATE_BADGE[state] ?? { tone: "outline" as const, label: state };
54
+ }
55
+
56
+ const TELEGRAM_USER_ID_RE = /^\d+$/;
57
+
58
+ function formatExpiry(expiresAt: string): string {
59
+ const ms = Date.parse(expiresAt) - Date.now();
60
+ if (!Number.isFinite(ms) || ms <= 0) return "expired";
61
+ const seconds = Math.ceil(ms / 1000);
62
+ const minutes = Math.floor(seconds / 60);
63
+ const rest = seconds % 60;
64
+ return `${minutes}:${rest.toString().padStart(2, "0")}`;
65
+ }
66
+
67
+ function isTerminalTelegramOnboardingError(error: unknown): boolean {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message);
70
+ }
71
+
72
+ export default function ChannelsPage() {
73
+ const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
74
+ const [loading, setLoading] = useState(true);
75
+ const { toast, showToast } = useToast();
76
+ const { setEnd } = usePageHeader();
77
+
78
+ // Config modal state
79
+ const [editing, setEditing] = useState<MessagingPlatform | null>(null);
80
+ const [draftEnv, setDraftEnv] = useState<Record<string, string>>({});
81
+ const [saving, setSaving] = useState(false);
82
+ const closeEdit = useCallback(() => setEditing(null), []);
83
+ const editModalRef = useModalBehavior({ open: editing !== null, onClose: closeEdit });
84
+
85
+ // Per-card busy + restart-needed tracking
86
+ const [togglingId, setTogglingId] = useState<string | null>(null);
87
+ const [testingId, setTestingId] = useState<string | null>(null);
88
+ const [restartNeeded, setRestartNeeded] = useState(false);
89
+ const [restarting, setRestarting] = useState(false);
90
+
91
+ const gatewayRunning = platforms.length > 0 && platforms[0].gateway_running;
92
+
93
+ const load = useCallback(() => {
94
+ return api
95
+ .getMessagingPlatforms()
96
+ .then((res) => setPlatforms(res.platforms))
97
+ .catch((e) => showToast(`Error: ${e}`, "error"));
98
+ }, [showToast]);
99
+
100
+ useEffect(() => {
101
+ load().finally(() => setLoading(false));
102
+ }, [load]);
103
+
104
+ const openConfig = (platform: MessagingPlatform) => {
105
+ const initial: Record<string, string> = {};
106
+ platform.env_vars.forEach((v) => {
107
+ initial[v.key] = "";
108
+ });
109
+ setDraftEnv(initial);
110
+ setEditing(platform);
111
+ };
112
+
113
+ const handleSave = async () => {
114
+ if (!editing) return;
115
+ // Only send fields the user actually filled in — leaving a field blank
116
+ // preserves the existing value rather than clobbering it.
117
+ const env: Record<string, string> = {};
118
+ Object.entries(draftEnv).forEach(([k, v]) => {
119
+ if (v.trim()) env[k] = v.trim();
120
+ });
121
+ if (Object.keys(env).length === 0) {
122
+ showToast("Nothing to save — fill in at least one field.", "error");
123
+ return;
124
+ }
125
+ const missing = editing.env_vars.filter(
126
+ (v) => v.required && !v.is_set && !env[v.key],
127
+ );
128
+ if (missing.length > 0) {
129
+ showToast(`${missing[0].prompt || missing[0].key} is required`, "error");
130
+ return;
131
+ }
132
+ setSaving(true);
133
+ try {
134
+ const body: MessagingPlatformUpdate = { env, enabled: true };
135
+ await api.updateMessagingPlatform(editing.id, body);
136
+ showToast(`${editing.name} saved`, "success");
137
+ setEditing(null);
138
+ setRestartNeeded(true);
139
+ await load();
140
+ } catch (e) {
141
+ showToast(`Failed to save: ${e}`, "error");
142
+ } finally {
143
+ setSaving(false);
144
+ }
145
+ };
146
+
147
+ const handleToggle = async (platform: MessagingPlatform) => {
148
+ const next = !platform.enabled;
149
+ setTogglingId(platform.id);
150
+ try {
151
+ await api.updateMessagingPlatform(platform.id, { enabled: next });
152
+ setPlatforms((prev) =>
153
+ prev.map((p) =>
154
+ p.id === platform.id
155
+ ? { ...p, enabled: next, state: next ? "pending_restart" : "disabled" }
156
+ : p,
157
+ ),
158
+ );
159
+ setRestartNeeded(true);
160
+ } catch (e) {
161
+ showToast(`Error: ${e}`, "error");
162
+ } finally {
163
+ setTogglingId(null);
164
+ }
165
+ };
166
+
167
+ const handleTest = async (platform: MessagingPlatform) => {
168
+ setTestingId(platform.id);
169
+ try {
170
+ const res = await api.testMessagingPlatform(platform.id);
171
+ showToast(`${platform.name}: ${res.message}`, res.ok ? "success" : "error");
172
+ } catch (e) {
173
+ showToast(`Error: ${e}`, "error");
174
+ } finally {
175
+ setTestingId(null);
176
+ }
177
+ };
178
+
179
+ const handleRestart = async () => {
180
+ setRestarting(true);
181
+ try {
182
+ await api.restartGateway();
183
+ showToast("Gateway restarting…", "success");
184
+ setRestartNeeded(false);
185
+ // Give the gateway a moment to come up, then refresh status.
186
+ setTimeout(() => void load(), 4000);
187
+ } catch (e) {
188
+ showToast(`Failed to restart: ${e}`, "error");
189
+ } finally {
190
+ setRestarting(false);
191
+ }
192
+ };
193
+
194
+ useLayoutEffect(() => {
195
+ setEnd(
196
+ <Button
197
+ className="uppercase"
198
+ size="sm"
199
+ onClick={handleRestart}
200
+ disabled={restarting}
201
+ prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
202
+ >
203
+ {restarting ? "Restarting…" : "Restart gateway"}
204
+ </Button>,
205
+ );
206
+ return () => setEnd(null);
207
+ // eslint-disable-next-line react-hooks/exhaustive-deps
208
+ }, [setEnd, restarting]);
209
+
210
+ const configured = useMemo(
211
+ () => platforms.filter((p) => p.configured).length,
212
+ [platforms],
213
+ );
214
+
215
+ if (loading) {
216
+ return (
217
+ <div className="flex items-center justify-center py-24">
218
+ <Spinner className="text-2xl text-primary" />
219
+ </div>
220
+ );
221
+ }
222
+
223
+ return (
224
+ <div className="flex flex-col gap-6">
225
+ <Toast toast={toast} />
226
+
227
+ {/* Restart banner */}
228
+ {restartNeeded && (
229
+ <Card className="border-warning/50">
230
+ <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
231
+ <div className="flex items-center gap-2 text-sm">
232
+ <AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
233
+ <span>
234
+ Changes are saved. Restart the gateway for them to take effect.
235
+ </span>
236
+ </div>
237
+ <Button
238
+ size="sm"
239
+ className="uppercase shrink-0"
240
+ onClick={handleRestart}
241
+ disabled={restarting}
242
+ prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
243
+ >
244
+ {restarting ? "Restarting…" : "Restart now"}
245
+ </Button>
246
+ </CardContent>
247
+ </Card>
248
+ )}
249
+
250
+ {!gatewayRunning && !restartNeeded && (
251
+ <Card className="border-border">
252
+ <CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
253
+ <WifiOff className="h-4 w-4 shrink-0" />
254
+ <span>
255
+ The gateway is not running. Configure channels here, then start the
256
+ gateway with <code className="font-courier">nastech gateway start</code>{" "}
257
+ (or the Restart button above).
258
+ </span>
259
+ </CardContent>
260
+ </Card>
261
+ )}
262
+
263
+ <p className="text-xs text-muted-foreground">
264
+ {configured} of {platforms.length} channels configured. Credentials are
265
+ written to <code className="font-courier">~/.nastech/.env</code>; the
266
+ gateway connects each enabled channel on its next restart.
267
+ </p>
268
+
269
+ {/* Config modal */}
270
+ {editing && (
271
+ <div
272
+ ref={editModalRef}
273
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
274
+ onClick={(e) => e.target === e.currentTarget && setEditing(null)}
275
+ role="dialog"
276
+ aria-modal="true"
277
+ aria-labelledby="channel-config-title"
278
+ >
279
+ <div
280
+ className={cn(
281
+ themedBody,
282
+ "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
283
+ )}
284
+ >
285
+ <Button
286
+ ghost
287
+ size="icon"
288
+ onClick={() => setEditing(null)}
289
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
290
+ aria-label="Close"
291
+ >
292
+ <X />
293
+ </Button>
294
+
295
+ <header className="p-5 pb-3 border-b border-border">
296
+ <h2
297
+ id="channel-config-title"
298
+ className="font-mondwest text-display text-base tracking-wider"
299
+ >
300
+ Configure {editing.name}
301
+ </h2>
302
+ {editing.docs_url && (
303
+ <a
304
+ href={editing.docs_url}
305
+ target="_blank"
306
+ rel="noopener noreferrer"
307
+ className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
308
+ >
309
+ Setup guide <ExternalLink className="h-3 w-3" />
310
+ </a>
311
+ )}
312
+ </header>
313
+
314
+ <div className="p-5 grid gap-4 overflow-y-auto">
315
+ <p className="text-xs text-muted-foreground">
316
+ {editing.description}
317
+ </p>
318
+ {editing.env_vars.map((field: MessagingPlatformEnvVar) => (
319
+ <div className="grid gap-1.5" key={field.key}>
320
+ <Label htmlFor={`field-${field.key}`}>
321
+ {field.prompt || field.key}
322
+ {field.required ? " *" : ""}
323
+ </Label>
324
+ {field.description && (
325
+ <span className="text-xs text-muted-foreground">
326
+ {field.description}
327
+ </span>
328
+ )}
329
+ <Input
330
+ id={`field-${field.key}`}
331
+ type={field.is_password ? "password" : "text"}
332
+ placeholder={
333
+ field.is_set
334
+ ? field.redacted_value || "•••••• (set — leave blank to keep)"
335
+ : field.key
336
+ }
337
+ value={draftEnv[field.key] ?? ""}
338
+ onChange={(e) =>
339
+ setDraftEnv((prev) => ({ ...prev, [field.key]: e.target.value }))
340
+ }
341
+ />
342
+ </div>
343
+ ))}
344
+
345
+ <div className="flex justify-end gap-2 pt-1">
346
+ <Button ghost size="sm" onClick={() => setEditing(null)}>
347
+ Cancel
348
+ </Button>
349
+ <Button
350
+ className="uppercase"
351
+ size="sm"
352
+ onClick={handleSave}
353
+ disabled={saving}
354
+ prefix={saving ? <Spinner /> : undefined}
355
+ >
356
+ {saving ? "Saving…" : "Save & enable"}
357
+ </Button>
358
+ </div>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ )}
363
+
364
+ {/* Platform list */}
365
+ <div className="grid gap-3">
366
+ {platforms.map((platform) => {
367
+ const badge = stateBadge(platform.state ?? "");
368
+ const busy = togglingId === platform.id;
369
+ const StateIcon =
370
+ platform.state === "connected"
371
+ ? CheckCircle2
372
+ : platform.state === "fatal"
373
+ ? AlertTriangle
374
+ : Radio;
375
+ return (
376
+ <Card key={platform.id} className="border-border">
377
+ <CardContent className="flex flex-col gap-4 p-4">
378
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
379
+ <div className="flex items-start gap-3 min-w-0">
380
+ <StateIcon
381
+ className={cn(
382
+ "h-5 w-5 shrink-0 mt-0.5",
383
+ platform.state === "connected"
384
+ ? "text-success"
385
+ : platform.state === "fatal"
386
+ ? "text-destructive"
387
+ : "text-muted-foreground",
388
+ )}
389
+ />
390
+ <div className="flex flex-col gap-0.5 min-w-0">
391
+ <div className="flex items-center gap-2 flex-wrap">
392
+ <span className="font-mondwest normal-case text-sm font-medium">
393
+ {platform.name}
394
+ </span>
395
+ <Badge tone={badge.tone}>{badge.label}</Badge>
396
+ </div>
397
+ <span className="text-xs text-muted-foreground">
398
+ {platform.description}
399
+ </span>
400
+ {platform.error_message && (
401
+ <span className="text-xs text-destructive">
402
+ {platform.error_message}
403
+ </span>
404
+ )}
405
+ </div>
406
+ </div>
407
+
408
+ <div className="flex items-center gap-2 shrink-0 self-start sm:self-center">
409
+ <div className="flex items-center gap-1.5">
410
+ {busy ? (
411
+ <Spinner className="text-sm" />
412
+ ) : (
413
+ <Switch
414
+ checked={platform.enabled}
415
+ onCheckedChange={() => void handleToggle(platform)}
416
+ aria-label={`Enable ${platform.name}`}
417
+ />
418
+ )}
419
+ </div>
420
+ <Button
421
+ ghost
422
+ size="sm"
423
+ onClick={() => handleTest(platform)}
424
+ disabled={testingId === platform.id}
425
+ prefix={
426
+ testingId === platform.id ? (
427
+ <Spinner />
428
+ ) : (
429
+ <PlugZap className="h-4 w-4" />
430
+ )
431
+ }
432
+ >
433
+ Test
434
+ </Button>
435
+ <Button
436
+ size="sm"
437
+ className="uppercase"
438
+ onClick={() => openConfig(platform)}
439
+ prefix={<Settings2 className="h-4 w-4" />}
440
+ >
441
+ Configure
442
+ </Button>
443
+ </div>
444
+ </div>
445
+ {platform.id === "telegram" && (
446
+ <TelegramOnboardingPanel
447
+ onChanged={load}
448
+ onRestartNeeded={() => setRestartNeeded(true)}
449
+ platform={platform}
450
+ setRestartNeeded={setRestartNeeded}
451
+ showToast={showToast}
452
+ />
453
+ )}
454
+ </CardContent>
455
+ </Card>
456
+ );
457
+ })}
458
+ </div>
459
+ </div>
460
+ );
461
+ }
462
+
463
+ function TelegramOnboardingPanel({
464
+ onChanged,
465
+ onRestartNeeded,
466
+ platform,
467
+ setRestartNeeded,
468
+ showToast,
469
+ }: {
470
+ onChanged: () => Promise<void>;
471
+ onRestartNeeded: () => void;
472
+ platform: MessagingPlatform;
473
+ setRestartNeeded: (needed: boolean) => void;
474
+ showToast: (message: string, type: "success" | "error") => void;
475
+ }) {
476
+ const [setup, setSetup] = useState<TelegramOnboardingStartResponse | null>(
477
+ null,
478
+ );
479
+ const [qrDataUrl, setQrDataUrl] = useState("");
480
+ const [phase, setPhase] = useState<
481
+ "idle" | "starting" | "waiting" | "ready" | "applying"
482
+ >("idle");
483
+ const [botUsername, setBotUsername] = useState<string | null>(null);
484
+ const [allowedIds, setAllowedIds] = useState<string[]>([]);
485
+ const [detectedOwnerId, setDetectedOwnerId] = useState<string | null>(null);
486
+ const [newAllowedId, setNewAllowedId] = useState("");
487
+ const [error, setError] = useState("");
488
+ const [tick, setTick] = useState(0);
489
+
490
+ useEffect(() => {
491
+ if (!setup || phase !== "waiting") return;
492
+ let cancelled = false;
493
+ let timeout: ReturnType<typeof setTimeout> | null = null;
494
+
495
+ const poll = async () => {
496
+ try {
497
+ const status = await api.getTelegramOnboardingStatus(setup.pairing_id);
498
+ if (cancelled) return;
499
+ if (status.status === "ready") {
500
+ setPhase("ready");
501
+ setBotUsername(status.bot_username ?? null);
502
+ setError("");
503
+ if (
504
+ status.owner_user_id &&
505
+ TELEGRAM_USER_ID_RE.test(status.owner_user_id)
506
+ ) {
507
+ setDetectedOwnerId(status.owner_user_id);
508
+ setAllowedIds([status.owner_user_id]);
509
+ }
510
+ return;
511
+ }
512
+ setError("");
513
+ timeout = setTimeout(poll, 2000);
514
+ } catch (pollError) {
515
+ if (cancelled) return;
516
+
517
+ const expiresAt = Date.parse(setup.expires_at);
518
+ const expired =
519
+ Number.isFinite(expiresAt) && Date.now() >= expiresAt;
520
+ if (isTerminalTelegramOnboardingError(pollError) || expired) {
521
+ setSetup(null);
522
+ setQrDataUrl("");
523
+ setPhase("idle");
524
+ setError("Telegram pairing expired. Start a new QR setup to try again.");
525
+ return;
526
+ }
527
+
528
+ setError(`Still waiting for Telegram. Retrying after: ${pollError}`);
529
+ timeout = setTimeout(poll, 2000);
530
+ }
531
+ };
532
+
533
+ timeout = setTimeout(poll, 1200);
534
+ return () => {
535
+ cancelled = true;
536
+ if (timeout) clearTimeout(timeout);
537
+ };
538
+ }, [phase, setup]);
539
+
540
+ useEffect(() => {
541
+ if (!setup) return;
542
+ const timer = setInterval(() => setTick((value) => value + 1), 1000);
543
+ return () => clearInterval(timer);
544
+ }, [setup]);
545
+
546
+ const resetSetup = () => {
547
+ setSetup(null);
548
+ setQrDataUrl("");
549
+ setPhase("idle");
550
+ setBotUsername(null);
551
+ setAllowedIds([]);
552
+ setDetectedOwnerId(null);
553
+ setNewAllowedId("");
554
+ setError("");
555
+ };
556
+
557
+ const start = async () => {
558
+ setPhase("starting");
559
+ setError("");
560
+ setBotUsername(null);
561
+ setAllowedIds([]);
562
+ setDetectedOwnerId(null);
563
+ setNewAllowedId("");
564
+ try {
565
+ const res = await api.startTelegramOnboarding({ bot_name: "NasTech Agent" });
566
+ const dataUrl = await QRCode.toDataURL(res.qr_payload, {
567
+ errorCorrectionLevel: "M",
568
+ margin: 1,
569
+ width: 224,
570
+ });
571
+ setSetup(res);
572
+ setQrDataUrl(dataUrl);
573
+ setPhase("waiting");
574
+ } catch (startError) {
575
+ setPhase("idle");
576
+ setError(String(startError));
577
+ }
578
+ };
579
+
580
+ const cancel = async () => {
581
+ if (setup) {
582
+ try {
583
+ await api.cancelTelegramOnboarding(setup.pairing_id);
584
+ } catch {
585
+ /* local cleanup still wins */
586
+ }
587
+ }
588
+ resetSetup();
589
+ };
590
+
591
+ const addAllowedId = () => {
592
+ const trimmed = newAllowedId.trim();
593
+ if (!TELEGRAM_USER_ID_RE.test(trimmed)) {
594
+ setError("Allowed Telegram user IDs must be numeric.");
595
+ return;
596
+ }
597
+ setError("");
598
+ setAllowedIds((ids) => (ids.includes(trimmed) ? ids : [...ids, trimmed]));
599
+ setNewAllowedId("");
600
+ };
601
+
602
+ const apply = async () => {
603
+ if (!setup) return;
604
+ if (allowedIds.length === 0) {
605
+ setError("Add at least one allowed Telegram user ID.");
606
+ return;
607
+ }
608
+ setPhase("applying");
609
+ setError("");
610
+ try {
611
+ await api.applyTelegramOnboarding(setup.pairing_id, {
612
+ allowed_user_ids: allowedIds,
613
+ });
614
+ resetSetup();
615
+ showToast("Telegram saved", "success");
616
+ try {
617
+ await api.restartGateway();
618
+ showToast("Gateway restarting…", "success");
619
+ setRestartNeeded(false);
620
+ setTimeout(() => void onChanged(), 4000);
621
+ } catch (restartError) {
622
+ onRestartNeeded();
623
+ showToast(`Telegram saved; restart failed: ${restartError}`, "error");
624
+ }
625
+ await onChanged();
626
+ } catch (applyError) {
627
+ setPhase("ready");
628
+ setError(String(applyError));
629
+ }
630
+ };
631
+
632
+ const expiresIn = useMemo(
633
+ () => (setup ? formatExpiry(setup.expires_at) : ""),
634
+ // tick keeps the memo fresh without recalculating on every render branch.
635
+ // eslint-disable-next-line react-hooks/exhaustive-deps
636
+ [setup, tick],
637
+ );
638
+
639
+ return (
640
+ <div className="rounded-sm border border-border bg-background/35 p-4">
641
+ <div className="flex flex-wrap items-center gap-2">
642
+ <Button
643
+ size="sm"
644
+ className="uppercase"
645
+ onClick={() => void start()}
646
+ disabled={phase === "starting" || phase === "waiting" || phase === "applying"}
647
+ prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />}
648
+ >
649
+ {phase === "starting" ? "Starting…" : "Set up with QR"}
650
+ </Button>
651
+ {platform.configured && (
652
+ <span className="text-xs text-muted-foreground">
653
+ Existing Telegram credentials are configured.
654
+ </span>
655
+ )}
656
+ </div>
657
+
658
+ {error && (
659
+ <div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
660
+ {error}
661
+ </div>
662
+ )}
663
+
664
+ {setup && qrDataUrl && (
665
+ <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
666
+ <div className="grid gap-3">
667
+ {(phase === "ready" || phase === "applying") && (
668
+ <div className="grid gap-3">
669
+ <div className="flex flex-wrap items-center gap-2">
670
+ <Badge tone="success">Ready</Badge>
671
+ {botUsername && (
672
+ <span className="font-courier text-sm text-muted-foreground">
673
+ @{botUsername}
674
+ </span>
675
+ )}
676
+ </div>
677
+
678
+ <div className="grid gap-2">
679
+ <div className="flex flex-wrap items-center gap-2">
680
+ <span className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
681
+ Allowed users
682
+ </span>
683
+ {detectedOwnerId && allowedIds.includes(detectedOwnerId) && (
684
+ <Badge tone="success">owner detected</Badge>
685
+ )}
686
+ </div>
687
+ <div className="flex flex-wrap gap-2">
688
+ {allowedIds.map((id) => (
689
+ <button
690
+ key={id}
691
+ type="button"
692
+ className="inline-flex items-center gap-1 border border-border px-2 py-1 font-courier text-xs text-foreground hover:border-destructive/50"
693
+ onClick={() =>
694
+ setAllowedIds((ids) =>
695
+ ids.filter((existing) => existing !== id),
696
+ )
697
+ }
698
+ >
699
+ {id}
700
+ <X className="h-3 w-3" />
701
+ </button>
702
+ ))}
703
+ {allowedIds.length === 0 && (
704
+ <span className="text-sm text-muted-foreground">
705
+ Add at least one Telegram user ID.
706
+ </span>
707
+ )}
708
+ </div>
709
+ </div>
710
+
711
+ <div className="flex flex-col gap-2 sm:flex-row">
712
+ <Input
713
+ value={newAllowedId}
714
+ onChange={(event) => setNewAllowedId(event.target.value)}
715
+ placeholder="Telegram user ID"
716
+ className="font-courier"
717
+ />
718
+ <Button size="sm" outlined onClick={addAllowedId} prefix={<Check />}>
719
+ Add
720
+ </Button>
721
+ </div>
722
+
723
+ <div className="flex flex-wrap gap-2">
724
+ <Button
725
+ size="sm"
726
+ className="uppercase"
727
+ onClick={() => void apply()}
728
+ disabled={phase === "applying"}
729
+ prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />}
730
+ >
731
+ {phase === "applying" ? "Saving…" : "Save and restart"}
732
+ </Button>
733
+ <Button size="sm" ghost onClick={() => void cancel()}>
734
+ Cancel
735
+ </Button>
736
+ </div>
737
+ </div>
738
+ )}
739
+ </div>
740
+
741
+ <div className="flex flex-col items-center justify-center gap-3">
742
+ <img
743
+ src={qrDataUrl}
744
+ alt="Telegram setup QR code"
745
+ className="h-56 w-56 bg-white p-2"
746
+ />
747
+ <div className="flex flex-wrap items-center justify-center gap-2 text-sm">
748
+ <Badge tone={expiresIn === "expired" ? "destructive" : "outline"}>
749
+ {expiresIn}
750
+ </Badge>
751
+ {phase === "waiting" && <Badge tone="warning">waiting</Badge>}
752
+ </div>
753
+ <div className="flex flex-wrap justify-center gap-2">
754
+ <a
755
+ href={setup.deep_link}
756
+ target="_blank"
757
+ rel="noreferrer"
758
+ className="inline-flex h-8 items-center gap-1 border border-border px-3 text-xs uppercase text-foreground hover:border-foreground/40"
759
+ >
760
+ <ExternalLink className="h-4 w-4" />
761
+ Open Telegram
762
+ </a>
763
+ <Button size="sm" ghost onClick={() => void cancel()}>
764
+ Cancel
765
+ </Button>
766
+ </div>
767
+ </div>
768
+ </div>
769
+ )}
770
+ </div>
771
+ );
772
+ }