@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.3

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 (239) hide show
  1. package/README.md +4 -4
  2. package/README.zh-TW.md +4 -4
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +14 -2
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/atomic-write.d.ts +21 -0
  7. package/dist/atomic-write.js +65 -0
  8. package/dist/atomic-write.js.map +1 -0
  9. package/dist/auth-api.js +2 -0
  10. package/dist/auth-api.js.map +1 -1
  11. package/dist/backend/codex.d.ts +7 -0
  12. package/dist/backend/codex.js +7 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/muse.d.ts +7 -0
  15. package/dist/backend/muse.js +7 -0
  16. package/dist/backend/muse.js.map +1 -1
  17. package/dist/backend/types.d.ts +6 -0
  18. package/dist/backend/types.js.map +1 -1
  19. package/dist/binary-probe.d.ts +80 -0
  20. package/dist/binary-probe.js +182 -0
  21. package/dist/binary-probe.js.map +1 -0
  22. package/dist/cache-service.d.ts +17 -1
  23. package/dist/cache-service.js +39 -4
  24. package/dist/cache-service.js.map +1 -1
  25. package/dist/channel/adapters/discord.d.ts +5 -1
  26. package/dist/channel/adapters/discord.js +56 -11
  27. package/dist/channel/adapters/discord.js.map +1 -1
  28. package/dist/channel/adapters/telegram.d.ts +38 -1
  29. package/dist/channel/adapters/telegram.js +82 -0
  30. package/dist/channel/adapters/telegram.js.map +1 -1
  31. package/dist/channel/mcp-tools.js +1 -1
  32. package/dist/channel/mcp-tools.js.map +1 -1
  33. package/dist/channel/tool-router.d.ts +8 -1
  34. package/dist/channel/tool-router.js +4 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +21 -0
  37. package/dist/cli.js +133 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/connection-secrets.d.ts +5 -0
  40. package/dist/connection-secrets.js.map +1 -1
  41. package/dist/cpu-profile.js +50 -0
  42. package/dist/cpu-profile.js.map +1 -1
  43. package/dist/daemon.d.ts +12 -0
  44. package/dist/daemon.js +166 -26
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/delivery-outbox.d.ts +1 -0
  47. package/dist/delivery-outbox.js +12 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/discord-permissions.d.ts +33 -0
  50. package/dist/discord-permissions.js +42 -0
  51. package/dist/discord-permissions.js.map +1 -0
  52. package/dist/env-file.d.ts +11 -0
  53. package/dist/env-file.js +27 -0
  54. package/dist/env-file.js.map +1 -0
  55. package/dist/fleet-context.d.ts +5 -0
  56. package/dist/fleet-context.js.map +1 -1
  57. package/dist/fleet-level-config.d.ts +8 -1
  58. package/dist/fleet-level-config.js +31 -0
  59. package/dist/fleet-level-config.js.map +1 -1
  60. package/dist/fleet-manager.d.ts +76 -7
  61. package/dist/fleet-manager.js +599 -127
  62. package/dist/fleet-manager.js.map +1 -1
  63. package/dist/fleet-stop-budget.d.ts +22 -0
  64. package/dist/fleet-stop-budget.js +95 -0
  65. package/dist/fleet-stop-budget.js.map +1 -0
  66. package/dist/kiro-transcript-lane.d.ts +8 -4
  67. package/dist/kiro-transcript-lane.js +66 -44
  68. package/dist/kiro-transcript-lane.js.map +1 -1
  69. package/dist/locale.js +82 -0
  70. package/dist/locale.js.map +1 -1
  71. package/dist/next-steps.d.ts +5 -0
  72. package/dist/next-steps.js +26 -0
  73. package/dist/next-steps.js.map +1 -0
  74. package/dist/outbound-handlers.js +8 -0
  75. package/dist/outbound-handlers.js.map +1 -1
  76. package/dist/outbound-schemas.d.ts +4 -0
  77. package/dist/outbound-schemas.js +6 -1
  78. package/dist/outbound-schemas.js.map +1 -1
  79. package/dist/outbox-open-error.d.ts +7 -0
  80. package/dist/outbox-open-error.js +15 -0
  81. package/dist/outbox-open-error.js.map +1 -0
  82. package/dist/pane-context-cache.d.ts +25 -0
  83. package/dist/pane-context-cache.js +73 -0
  84. package/dist/pane-context-cache.js.map +1 -0
  85. package/dist/paths.d.ts +0 -7
  86. package/dist/paths.js +8 -2
  87. package/dist/paths.js.map +1 -1
  88. package/dist/provider-probe.d.ts +15 -0
  89. package/dist/provider-probe.js +20 -0
  90. package/dist/provider-probe.js.map +1 -1
  91. package/dist/public-link-progress.d.ts +73 -0
  92. package/dist/public-link-progress.js +190 -0
  93. package/dist/public-link-progress.js.map +1 -0
  94. package/dist/public-web-gateway.js +1 -1
  95. package/dist/public-web-gateway.js.map +1 -1
  96. package/dist/public-web-link.d.ts +6 -1
  97. package/dist/public-web-link.js +87 -15
  98. package/dist/public-web-link.js.map +1 -1
  99. package/dist/quickstart-api.d.ts +93 -20
  100. package/dist/quickstart-api.js +194 -62
  101. package/dist/quickstart-api.js.map +1 -1
  102. package/dist/quickstart.d.ts +15 -0
  103. package/dist/quickstart.js +100 -29
  104. package/dist/quickstart.js.map +1 -1
  105. package/dist/reply-buttons-controller.d.ts +98 -0
  106. package/dist/reply-buttons-controller.js +174 -0
  107. package/dist/reply-buttons-controller.js.map +1 -0
  108. package/dist/reply-buttons.d.ts +113 -0
  109. package/dist/reply-buttons.js +208 -0
  110. package/dist/reply-buttons.js.map +1 -0
  111. package/dist/reply-dedup.js +3 -1
  112. package/dist/reply-dedup.js.map +1 -1
  113. package/dist/restart-guard.d.ts +1 -0
  114. package/dist/restart-guard.js +8 -3
  115. package/dist/restart-guard.js.map +1 -1
  116. package/dist/service-activation.d.ts +15 -2
  117. package/dist/service-activation.js +81 -4
  118. package/dist/service-activation.js.map +1 -1
  119. package/dist/service-installer.js +19 -1
  120. package/dist/service-installer.js.map +1 -1
  121. package/dist/settings-api.js +53 -12
  122. package/dist/settings-api.js.map +1 -1
  123. package/dist/settings-authority.d.ts +11 -0
  124. package/dist/settings-authority.js +128 -0
  125. package/dist/settings-authority.js.map +1 -0
  126. package/dist/settings-change.d.ts +5 -0
  127. package/dist/settings-change.js +4 -3
  128. package/dist/settings-change.js.map +1 -1
  129. package/dist/settings-confirmation.d.ts +3 -0
  130. package/dist/settings-confirmation.js +7 -1
  131. package/dist/settings-confirmation.js.map +1 -1
  132. package/dist/settings-effect.js +19 -7
  133. package/dist/settings-effect.js.map +1 -1
  134. package/dist/settings-http-confirmation.js +1 -1
  135. package/dist/settings-http-confirmation.js.map +1 -1
  136. package/dist/settings-redaction.d.ts +18 -0
  137. package/dist/settings-redaction.js +58 -0
  138. package/dist/settings-redaction.js.map +1 -0
  139. package/dist/setup-form.d.ts +9 -1
  140. package/dist/setup-form.js +25 -1
  141. package/dist/setup-form.js.map +1 -1
  142. package/dist/setup-host.js +13 -3
  143. package/dist/setup-host.js.map +1 -1
  144. package/dist/setup-wizard.js +26 -19
  145. package/dist/setup-wizard.js.map +1 -1
  146. package/dist/systemd-runtime.d.ts +15 -0
  147. package/dist/systemd-runtime.js +41 -0
  148. package/dist/systemd-runtime.js.map +1 -0
  149. package/dist/tmux-control.d.ts +22 -0
  150. package/dist/tmux-control.js +82 -17
  151. package/dist/tmux-control.js.map +1 -1
  152. package/dist/token-env-name.d.ts +37 -0
  153. package/dist/token-env-name.js +93 -0
  154. package/dist/token-env-name.js.map +1 -0
  155. package/dist/tool-permissions.js +5 -3
  156. package/dist/tool-permissions.js.map +1 -1
  157. package/dist/topic-commands.d.ts +7 -16
  158. package/dist/topic-commands.js +45 -83
  159. package/dist/topic-commands.js.map +1 -1
  160. package/dist/transcript-monitor.js +3 -0
  161. package/dist/transcript-monitor.js.map +1 -1
  162. package/dist/transcript-sources.d.ts +68 -0
  163. package/dist/transcript-sources.js +336 -29
  164. package/dist/transcript-sources.js.map +1 -1
  165. package/dist/transcript-turns.d.ts +39 -0
  166. package/dist/transcript-turns.js +58 -0
  167. package/dist/transcript-turns.js.map +1 -0
  168. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  169. package/dist/tunnel/cloudflared-install.js +15 -1
  170. package/dist/tunnel/cloudflared-install.js.map +1 -1
  171. package/dist/turn-reply-guard.d.ts +3 -0
  172. package/dist/turn-reply-guard.js.map +1 -1
  173. package/dist/ui/chat-render.js +32 -6
  174. package/dist/ui/chat-store.js +21 -1
  175. package/dist/ui/chat-strings.js +8 -0
  176. package/dist/ui/chat-thread.js +109 -52
  177. package/dist/ui/first-run.js +64 -0
  178. package/dist/ui/html-attachment.js +26 -0
  179. package/dist/ui/panel-chat.js +13 -5
  180. package/dist/ui/panel-fleet.js +5 -5
  181. package/dist/ui/panel-settings.js +18 -8
  182. package/dist/ui/preview-panel.js +38 -3
  183. package/dist/ui/settings-apply.js +3 -1
  184. package/dist/ui/settings-dialogs.js +95 -25
  185. package/dist/ui/settings-discord.js +130 -0
  186. package/dist/ui/settings-model.js +21 -0
  187. package/dist/ui/settings-strings.js +46 -6
  188. package/dist/ui/settings-token.js +54 -0
  189. package/dist/ui/settings-wizard.js +63 -29
  190. package/dist/ui/shared/app-shell.js +19 -33
  191. package/dist/ui/shared/app-store.js +5 -3
  192. package/dist/ui/shared/app-stream.js +2 -1
  193. package/dist/ui/shared/app.css +34 -2
  194. package/dist/ui/shared/app.js +2 -1
  195. package/dist/ui/shared/instance-nav.js +310 -0
  196. package/dist/ui/shared/panel-view.js +12 -190
  197. package/dist/ui/shared/view-roster-store.js +5 -0
  198. package/dist/ui/shared/view-strings.js +12 -0
  199. package/dist/update-dispatch.d.ts +1 -1
  200. package/dist/update-dispatch.js +1 -1
  201. package/dist/update-install.d.ts +15 -3
  202. package/dist/update-install.js +41 -13
  203. package/dist/update-install.js.map +1 -1
  204. package/dist/update-launch.d.ts +22 -0
  205. package/dist/update-launch.js +106 -0
  206. package/dist/update-launch.js.map +1 -0
  207. package/dist/update-marker.js +3 -8
  208. package/dist/update-marker.js.map +1 -1
  209. package/dist/usage/format-rich.js +7 -1
  210. package/dist/usage/format-rich.js.map +1 -1
  211. package/dist/usage/i18n-keys.d.ts +1 -1
  212. package/dist/usage/i18n-keys.js +1 -1
  213. package/dist/usage/i18n-keys.js.map +1 -1
  214. package/dist/usage/providers.d.ts +4 -0
  215. package/dist/usage/providers.js +2 -1
  216. package/dist/usage/providers.js.map +1 -1
  217. package/dist/usage/usage-api.js +48 -2
  218. package/dist/usage/usage-api.js.map +1 -1
  219. package/dist/view-api.d.ts +40 -0
  220. package/dist/view-api.js +28 -10
  221. package/dist/view-api.js.map +1 -1
  222. package/dist/web-api.d.ts +8 -0
  223. package/dist/web-api.js +50 -16
  224. package/dist/web-api.js.map +1 -1
  225. package/dist/web-auth.d.ts +8 -0
  226. package/dist/web-auth.js +34 -8
  227. package/dist/web-auth.js.map +1 -1
  228. package/dist/web-chat-history.d.ts +21 -0
  229. package/dist/web-chat-history.js +44 -0
  230. package/dist/web-chat-history.js.map +1 -1
  231. package/dist/web-host-guard.d.ts +18 -2
  232. package/dist/web-host-guard.js +30 -5
  233. package/dist/web-host-guard.js.map +1 -1
  234. package/dist/web-session.d.ts +7 -1
  235. package/dist/web-session.js +23 -2
  236. package/dist/web-session.js.map +1 -1
  237. package/launcher/postinstall.cjs +8 -4
  238. package/package.json +1 -1
  239. package/templates/systemd.service.ejs +3 -3
@@ -10,6 +10,9 @@ import { Icon } from "/assets/ui-icons.js";
10
10
  import { toast } from "/assets/ui-toast.js";
11
11
  import { confirmDialog } from "/assets/ui-confirm.js";
12
12
  import { api, confirmedWrite, newKey } from "./settings-confirm.js";
13
+ import { botHandle, TokenEnvNote, TokenField, verifyBotToken } from "./settings-token.js";
14
+ import { DiscordServerPicker } from "./settings-discord.js";
15
+ import { startOperation } from "./settings-apply.js";
13
16
  import {
14
17
  ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
15
18
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
@@ -341,15 +344,31 @@ export function BotDialog({ id, ctx, onClose }) {
341
344
  const [bindBusy, setBindBusy] = useState(false);
342
345
  const [token, setToken] = useState("");
343
346
  const [tokenNote, setTokenNote] = useState(null);
347
+ // Replace token (#1519 P1): the field opens on Replace, and a token is staged only once the platform has named its bot.
348
+ const [replacing, setReplacing] = useState(false);
349
+ const [tokenId, setTokenId] = useState(null);
350
+ const [tokenBusy, setTokenBusy] = useState(false);
351
+ const status = (ctx.connections || []).find((c) => c && c.id === id) || {};
344
352
  const [emojis, setEmojis] = useState({ value: null, baseline: null });
345
353
  const lease = useLease("bot-dialog");
346
354
  const accessWarn = mode === "locked" && users.length === 0 ? "accessLockedEmpty" : "";
355
+ // The token's revision (#1529 review): typing moves it, and a Verify for an older value is dropped when it lands.
356
+ const tokenRev = useRef(0);
357
+ const verifyToken = async () => {
358
+ setTokenBusy(true);
359
+ const mine = epoch.current, at = tokenRev.current;
360
+ const identity = await verifyBotToken(type, token);
361
+ if (!lease.current() || epoch.current !== mine) return;
362
+ setTokenBusy(false);
363
+ if (tokenRev.current === at) setTokenId(identity);
364
+ };
347
365
  const stageToken = () => {
348
366
  if (!ch) return;
349
367
  if (!token) { setTokenNote({ error: tn("tokenRequired") }); return; }
368
+ if (!tokenId || !tokenId.valid) { setTokenNote({ error: tn("wizardNeedVerify") }); return; }
350
369
  // The token goes into the staged change and nowhere else: not a label, not the URL, not storage.
351
370
  ctx.stage(`secret:${id}`, { label: tn("rotateToken", type), impact: schema.order[0], connectionSecret: { id, secret: token } });
352
- setToken("");
371
+ setToken(""); setTokenId(null); setReplacing(false);
353
372
  setTokenNote({ text: tn("tokenQueued") });
354
373
  };
355
374
  const stage = () => {
@@ -419,45 +438,96 @@ export function BotDialog({ id, ctx, onClose }) {
419
438
  previewBody=${(map) => ({ channel_id: id, platform: type, channel_config: map })} onChange=${setEmojis} />
420
439
  </${Drawer}>
421
440
  <${Drawer} title=${tn("advancedSection")}>
422
- <div class="field"><span>${tn("tokenEnv")}</span><code>${ch.bot_token_env || tn("noTokenEnv")}</code></div>
423
- <div class="field"><label for="bot-token">${tn("tokenField")}</label>
424
- <input id="bot-token" type="password" autocomplete="new-password" value=${token} placeholder=${tn("tokenPlaceholder")} onInput=${(e) => setToken(e.target.value)} />
425
- <p class="note">${tn("tokenHint")}</p>
426
- <div class="dlg-inline-actions"><button type="button" class="btn btn-sm" onClick=${stageToken}>${tn("tokenButton")}</button>
427
- ${tokenNote ? html`<span class=${`feedback${tokenNote.error ? " error" : ""}`} role="status">${tokenNote.error || tokenNote.text}</span>` : null}</div></div>
441
+ <div class="field"><span>${tn("tokenStatus")}</span>
442
+ <div class="dlg-inline-actions"><span class="token-status">${status.token_present === false ? tn("tokenMissing")
443
+ : status.identity && status.identity.username ? tn("tokenSetAs", botHandle(status.identity.username)) : tn("tokenSet")}</span>
444
+ ${replacing ? null : html`<button type="button" class="btn btn-sm" onClick=${() => { setReplacing(true); setTokenNote(null); }}>${tn("tokenReplace")}</button>`}</div></div>
445
+ ${replacing ? html`<${TokenField} id="bot-token" platform=${type} value=${token} identity=${tokenId} busy=${tokenBusy}
446
+ onInput=${(v) => { tokenRev.current++; setToken(v); setTokenId(null); }} onVerify=${verifyToken} hint=${tn("tokenHint")} />
447
+ <div class="dlg-inline-actions"><button type="button" class="btn btn-sm" disabled=${!tokenId || !tokenId.valid} onClick=${stageToken}>${tn("tokenButton")}</button>
448
+ <button type="button" class="btn btn-sm btn-ghost" onClick=${() => { tokenRev.current++; setReplacing(false); setToken(""); setTokenId(null); }}>${tn("cancel")}</button></div>` : null}
449
+ ${tokenNote ? html`<p class=${`feedback${tokenNote.error ? " error" : ""}`} role="status">${tokenNote.error || tokenNote.text}</p>` : null}
450
+ <${TokenEnvNote} name=${ch.bot_token_env} />
428
451
  <div class="dlg-inline-actions"><button type="button" class="btn btn-sm danger" onClick=${remove}><${Icon} name="trash" size=${14} />${tn("deleteBot")}</button></div>
429
452
  </${Drawer}>
430
453
  </${EditDialog}>`;
431
454
  }
432
455
 
433
- /** A new connection: written at once (as before), through the confirmation an admin may have to give. */
456
+ /**
457
+ * A new connection (#1519 P1): the bot token, checked with the platform, then the server-side plan — a free id and a
458
+ * generated token env (shown under Advanced, read-only) — then the same write the setup wizard makes, without an agent:
459
+ * one operation (the confirmation an admin may have to give, then Apply, which offers the restart a new connection
460
+ * needs). Never a replacement: the server refuses an id or token env another connection holds.
461
+ */
434
462
  export function NewBotDialog({ ctx, onClose }) {
435
463
  const lease = useLease("new-bot");
436
- const [f, setF] = useState({ type: "discord", id: "", env: "", group: "" });
464
+ const [f, setF] = useState({ type: "discord", id: "", token: "", identity: null, guilds: [], group: "", general: "" });
465
+ const [plan, setPlan] = useState(null);
437
466
  const [busy, setBusy] = useState(false);
438
467
  const [err, setErr] = useState("");
468
+ // The form's revision (#1529 review): any change to the token, platform, id or group moves it; a Verify or a plan
469
+ // that began before is dropped when it lands — never restoring an old token, naming it, or planning for it.
470
+ const rev = useRef(0);
471
+ const body = (x) => ({ platform: x.type, connection_only: true, ...(x.id.trim() ? { channel_id: x.id.trim() } : {}),
472
+ ...(x.group.trim() ? (x.type === "discord" ? { guild_id: x.group.trim() } : { group_id: x.group.trim() }) : {}),
473
+ ...(x.type === "discord" && x.general.trim() ? { general_channel_id: x.general.trim() } : {}) });
474
+ const planFor = async (x) => {
475
+ const at = rev.current;
476
+ const res = await api("/api/settings/quickstart/plan", { method: "POST", body: JSON.stringify(body(x)) }).catch(() => ({ ok: false, body: {} }));
477
+ if (!lease.current() || rev.current !== at) return null;
478
+ if (!res.ok) { setErr((res.body && res.body.error) || tn("failed")); setPlan(null); return null; }
479
+ setErr(""); setPlan(res.body); return res.body;
480
+ };
481
+ const verify = async () => {
482
+ const at = rev.current, x = f;
483
+ const live = () => lease.current() && rev.current === at;
484
+ setBusy(true); setErr("");
485
+ const identity = await verifyBotToken(x.type, x.token);
486
+ if (!live()) { if (lease.current()) setBusy(false); return; }
487
+ let guilds = [];
488
+ if (identity.valid && x.type === "discord") {
489
+ const g = await api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify({ action: "guilds", token: x.token }) }).catch(() => null);
490
+ if (!live()) { if (lease.current()) setBusy(false); return; }
491
+ guilds = (g && g.body && g.body.guilds) || [];
492
+ }
493
+ // Onto the form as it is now (same revision, so the same token): never a copy of the form from before the await.
494
+ setF((cur) => ({ ...cur, identity, guilds })); setBusy(false);
495
+ if (identity.valid) await planFor({ ...x, identity, guilds });
496
+ };
439
497
  const save = async () => {
440
- if (!f.env.trim()) { setErr(tn("tokenEnvRequired")); return; }
441
- const next = [...channelsOf(ctx.fleet), { id: f.id.trim() || undefined, type: f.type, bot_token_env: f.env.trim(), group_id: f.group.trim() || undefined, access: { mode: "locked", allowed_users: [] } }];
498
+ if (!f.identity || !f.identity.valid) { setErr(tn("wizardNeedVerify")); return; }
499
+ const x = f;
442
500
  setBusy(true);
443
- const res = await confirmedWrite("/api/settings/fleet/channels", { method: "PUT", body: next, label: tn("addBotLabel", f.id.trim() || f.type) }).catch(() => ({ ok: false, body: {} }));
444
- if (!res.ok) { toast((res.body && res.body.error) || tn("failed"), false); if (lease.current()) setBusy(false); return; }
445
- toast(tn("saved"));
446
- ctx.reload();
447
- if (lease.current()) onClose();
501
+ const p = await planFor(x);
502
+ if (!p) { if (lease.current()) setBusy(false); return; }
503
+ const handed = startOperation([{ label: tn("addBotLabel", p.channel_id), impact: impactOf(ctx.schema, "fleet.channels"),
504
+ request: { method: "POST", url: "/api/settings/quickstart/commit",
505
+ body: { ...body(x), channel_id: p.channel_id, token_env: p.token_env, token_env_generated: true, token: x.token }, sensitive: true } }]);
506
+ if (!handed) { setErr(tn("applyBusyLocal")); setBusy(false); return; }
507
+ setF((x) => ({ ...x, token: "" }));
508
+ onClose();
448
509
  };
449
- const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
510
+ // A change of platform or id is another connection: what was verified and planned no longer applies. The server or
511
+ // group does not change the id or the token's name, so the plan (and its name under Advanced) stays.
512
+ const set = (k) => (v) => { rev.current++; setF((x) => ({ ...x, [k]: v, ...(k === "type" ? { identity: null, guilds: [], group: "", general: "" } : {}) })); if (k !== "group" && k !== "general") setPlan(null); };
450
513
  return html`<${Dialog} title=${tn("newBot")} onClose=${onClose} busy=${busy}
451
514
  actions=${html`<button type="button" class="btn" disabled=${busy} onClick=${onClose}>${tn("cancel")}</button>
452
- <button type="button" class="btn btn-primary" disabled=${busy} onClick=${save}>${tn("save")}</button>`}>
515
+ <button type="button" class="btn btn-primary" disabled=${busy || !f.identity || !f.identity.valid} onClick=${save}>${tn("save")}</button>`}>
453
516
  <div class="form">
454
- <div class="grid2">
455
- <div class="field"><label for="nb-type">${tn("type")}</label><${Select} id="nb-type" value=${f.type} onChange=${(v) => setF({ ...f, type: v })} options=${CH_TYPES} /></div>
456
- <div class="field"><label for="nb-id">${tn("adapterId")}</label><input id="nb-id" type="text" placeholder="persona1" value=${f.id} onInput=${set("id")} /></div>
457
- <div class="field"><label for="nb-env">${tn("tokenEnv")} *</label><input id="nb-env" type="text" placeholder="BOT_TOKEN_ENV" value=${f.env} onInput=${set("env")} /></div>
458
- <div class="field"><label for="nb-group">${tn("groupId")}</label><input id="nb-group" type="text" value=${f.group} onInput=${set("group")} /></div>
459
- </div>
460
- <p class="note">${tn("envHint")}</p>
517
+ <div class="field"><label for="nb-type">${tn("type")}</label><${Select} id="nb-type" value=${f.type} onChange=${set("type")} options=${CH_TYPES} /></div>
518
+ <${TokenField} id="nb-token" platform=${f.type} value=${f.token} identity=${f.identity} busy=${busy}
519
+ onInput=${(v) => { rev.current++; setF((x) => ({ ...x, token: v, identity: null, guilds: [] })); setPlan(null); }} onVerify=${verify} hint=${tn("newBotTokenHint")} />
520
+ ${f.type === "discord"
521
+ ? html`<${DiscordServerPicker} idPrefix="nb" token=${f.identity && f.identity.valid ? f.token : ""} bot=${f.identity && botHandle(f.identity.username)}
522
+ invite=${f.identity && f.identity.invite} portal=${f.identity && f.identity.portal} guilds=${f.guilds} onGuilds=${(g) => setF((x) => ({ ...x, guilds: g }))}
523
+ guild=${f.group} onGuild=${set("group")} channel=${f.general} onChannel=${set("general")} />`
524
+ : html`<div class="field"><label for="nb-group">${tn("groupIdField")}</label>
525
+ <input id="nb-group" type="text" value=${f.group} onInput=${(e) => set("group")(e.target.value.trim())} /></div>`}
526
+ <${Drawer} title=${tn("advancedSection")}>
527
+ <div class="field"><label for="nb-id">${tn("connectionIdOptional")}</label><input id="nb-id" type="text" placeholder=${plan ? plan.channel_id : f.type}
528
+ value=${f.id} onInput=${(e) => set("id")(e.target.value)} onBlur=${() => { if (f.identity && f.identity.valid) planFor(f); }} /></div>
529
+ <${TokenEnvNote} name=${plan && plan.token_env} />
530
+ </${Drawer}>
461
531
  ${err ? html`<p class="feedback error" role="alert">${err}</p>` : null}
462
532
  </div></${Dialog}>`;
463
533
  }
@@ -0,0 +1,130 @@
1
+ // #1519 P3 (docs/design/ux-onboarding-walkthrough.md §5.3): after a Discord token verifies — the invite (one permission
2
+ // set, never Administrator), the Message Content note with the portal's Bot page, the server the bot joins found by
3
+ // asking Discord every 2 s for up to 2 minutes, and the server's text channels as a list ("general" first choice). The
4
+ // setup wizard and New connection use it.
5
+ //
6
+ // Every read is an operation of its own (#1533 review): numbered when it starts, and its answer is taken only while it is
7
+ // still the current one of its kind and this picker is still on the page. A new token, another server, a manual choice,
8
+ // Stop, the 2-minute limit or leaving the page each retire what was asked before — an answer about A never lands after
9
+ // B, nor after A again (an older A), nor once the page is gone.
10
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
+ import { t } from "/assets/app-i18n.js";
12
+ import { api } from "./settings-confirm.js";
13
+ import { Select } from "./settings-dialogs.js";
14
+
15
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
16
+ export const POLL_MS = 2000, POLL_FOR_MS = 120_000;
17
+ const clock = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); // elapsed time: monotonic
18
+ const probe = (body) => api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify(body) }).catch(() => ({ ok: false, body: {} }));
19
+
20
+ /**
21
+ * props: idPrefix, token, bot (@name), invite, portal, guilds, onGuilds(list), guild, onGuild(id), channel, onChannel(id).
22
+ * The parent owns the values; this owns the asking.
23
+ */
24
+ export function DiscordServerPicker({ idPrefix, token, bot, invite, portal, guilds, onGuilds, guild, onGuild, channel, onChannel }) {
25
+ const [polling, setPolling] = useState(null); // { timedOut } while waiting for the bot to join
26
+ const [channels, setChannels] = useState(null); // null: not asked; { list } | { error }
27
+ const live = useRef(true);
28
+ const now = useRef({ token, guild, channel });
29
+ now.current = { token, guild, channel };
30
+ // The current operation of each kind; an answer is taken only while its number is still the one here.
31
+ // `choice`: the one generation of the server choice that polls and Check again both write (#1533 review r2) — a read
32
+ // started before any later pick, Check again or Stop cannot pick.
33
+ const op = useRef({ poll: 0, check: 0, channels: 0, choice: 0 });
34
+ const poll = useRef({ timer: null, until: 0, known: new Set(), asking: false });
35
+ const chosen = useRef(false); // the person picked a General channel since this server's list was asked
36
+
37
+ const stopPolling = (timedOut = false) => {
38
+ op.current.poll++; op.current.choice++; // retires the poll's reads still on their way
39
+ if (poll.current.timer) { clearInterval(poll.current.timer); poll.current.timer = null; }
40
+ setPolling(timedOut ? { timedOut: true } : null);
41
+ };
42
+ useEffect(() => () => { live.current = false; if (poll.current.timer) clearInterval(poll.current.timer); }, []);
43
+ // A new token is another bot: what was being waited for, or listed, was about the old one — and so was its General.
44
+ const lastToken = useRef(token);
45
+ useEffect(() => {
46
+ op.current.check++; op.current.channels++; stopPolling(); setChannels(null);
47
+ if (lastToken.current !== token && now.current.channel) onChannel("");
48
+ lastToken.current = token;
49
+ }, [token]);
50
+
51
+ /** One read of the bot's servers, for operation `kind` number `seq`; joined: pick it (unless the person chose meanwhile). */
52
+ const readGuilds = async (kind, seq, known, deadline) => {
53
+ const asked = token, choice = op.current.choice;
54
+ const res = await probe({ action: "guilds", token: asked });
55
+ const current = () => live.current && op.current[kind] === seq && op.current.choice === choice && now.current.token === asked
56
+ && (deadline == null || clock() <= deadline);
57
+ if (!current()) return false;
58
+ const list = (res.ok && res.body && Array.isArray(res.body.guilds)) ? res.body.guilds : null;
59
+ if (!list) return false;
60
+ onGuilds(list);
61
+ const joined = list.filter((g) => !known.has(g.id));
62
+ if (!joined.length) return false;
63
+ if (joined.length === 1 || !now.current.guild) pickGuild(joined[0].id, false);
64
+ return true;
65
+ };
66
+ const startPolling = () => {
67
+ stopPolling();
68
+ const seq = op.current.poll, known = new Set((guilds || []).map((g) => g.id)), until = clock() + POLL_FOR_MS;
69
+ poll.current = { timer: null, until, known, asking: false };
70
+ setPolling({ timedOut: false });
71
+ const record = poll.current;
72
+ record.timer = setInterval(async () => {
73
+ if (op.current.poll !== seq) return;
74
+ if (clock() > until) { stopPolling(true); return; }
75
+ if (record.asking) return; // one read at a time
76
+ record.asking = true;
77
+ // Its own record only: a later poll's in-flight flag is not this read's to clear.
78
+ await readGuilds("poll", seq, known, until).finally(() => { record.asking = false; });
79
+ }, POLL_MS);
80
+ };
81
+ // Check again: a new, explicit read of its own (it does not revive a stopped poll).
82
+ // A newer, explicit read: it supersedes any read still on its way (theirs no longer picks).
83
+ const checkAgain = () => { op.current.choice++; const seq = ++op.current.check; void readGuilds("check", seq, poll.current.known, null); };
84
+
85
+ /** A server is picked — by the person (`manual`) or because the bot joined it. Either way the old General goes. */
86
+ const pickGuild = (id, manual) => {
87
+ // Any pick — the person's, or a read's — ends the waiting and retires every other read still on its way.
88
+ stopPolling(); op.current.check++;
89
+ if (id !== now.current.guild && now.current.channel) onChannel("");
90
+ onGuild(id);
91
+ };
92
+ const pickChannel = (id) => { chosen.current = true; onChannel(id); };
93
+
94
+ // The chosen server's text channels; "general" when there is one and none is chosen yet.
95
+ useEffect(() => {
96
+ const seq = ++op.current.channels;
97
+ chosen.current = false;
98
+ setChannels(null);
99
+ if (!guild || !token) return;
100
+ const askedToken = token, askedGuild = guild;
101
+ (async () => {
102
+ const res = await probe({ action: "channels", token: askedToken, guild_id: askedGuild });
103
+ if (!live.current || op.current.channels !== seq || now.current.token !== askedToken || now.current.guild !== askedGuild) return;
104
+ if (!res.ok || !res.body || !Array.isArray(res.body.channels)) { setChannels({ error: (res.body && res.body.error) || tn("failed") }); return; }
105
+ setChannels({ list: res.body.channels });
106
+ // The first choice only fills an empty choice the person has not made.
107
+ if (!chosen.current && !now.current.channel) {
108
+ const general = res.body.channels.find((c) => c.name.toLowerCase() === "general");
109
+ if (general) onChannel(general.id);
110
+ }
111
+ })();
112
+ }, [guild, token]);
113
+
114
+ return html`<div class="discord-setup">
115
+ ${invite ? html`<div class="dlg-inline-actions">
116
+ <a class="btn btn-sm btn-primary" href=${invite} target="_blank" rel="noopener noreferrer" onClick=${startPolling}>${tn("discordInvite", bot || tn("discordTheBot"))}</a>
117
+ ${polling && !polling.timedOut ? html`<span class="note" role="status">${tn("discordWaitingJoin")}</span>` : null}
118
+ ${polling ? html`<button type="button" class="btn btn-sm btn-ghost" onClick=${checkAgain}>${tn("discordCheckAgain")}</button>` : null}</div>
119
+ <p class="note">${tn("discordIntentNote")} ${portal ? html`<a href=${portal} target="_blank" rel="noopener noreferrer">${tn("discordPortalBot")}</a>` : null}</p>` : null}
120
+ <div class="field"><label for=${`${idPrefix}-guild`}>${tn("guildIdField")}</label>
121
+ ${guilds && guilds.length
122
+ ? html`<${Select} id=${`${idPrefix}-guild`} value=${guild} onChange=${(v) => pickGuild(v, true)} options=${["", ...guilds.map((g) => ({ value: g.id, label: `${g.name} (${g.id})` }))]} />`
123
+ : html`<input id=${`${idPrefix}-guild`} type="text" value=${guild} placeholder=${tn("discordNoServerYet")} onInput=${(e) => pickGuild(e.target.value.trim(), true)} />`}</div>
124
+ <div class="field"><label for=${`${idPrefix}-gen`}>${tn("wizardGeneralChannel")}</label>
125
+ ${channels && channels.list && channels.list.length
126
+ ? html`<${Select} id=${`${idPrefix}-gen`} value=${channel} onChange=${pickChannel} options=${["", ...channels.list.map((c) => ({ value: c.id, label: `#${c.name}` }))]} />`
127
+ : html`<input id=${`${idPrefix}-gen`} type="text" value=${channel} onInput=${(e) => pickChannel(e.target.value.trim())} />`}
128
+ ${channels && channels.error ? html`<p class="feedback warning">${channels.error}</p>` : null}</div>
129
+ </div>`;
130
+ }
@@ -275,3 +275,24 @@ export function fullModelRequests(model, current) {
275
275
  }
276
276
  return out;
277
277
  }
278
+
279
+ /**
280
+ * #1519 P6 (design §5.6): one connection's own state, from its own metadata (GET /api/settings/connections) — not the
281
+ * fleet's. { key, cls, bot }: key names the settings string, cls the dot ("ok" | "warn" | "bad" | "off"), bot its @name.
282
+ * In order: no fleet answering, no token, a named problem, then the adapter's own status.
283
+ */
284
+ export function connectionState(meta, fleetUp) {
285
+ const m = meta || {};
286
+ if (!fleetUp) return { key: "problem", cls: "bad" };
287
+ if (m.token_present === false) return { key: "connTokenMissing", cls: "bad" };
288
+ if (m.problem === "missing_intent") return { key: "discordMissingIntent", cls: "bad" };
289
+ if (m.problem === "rejected") return { key: "connRejected", cls: "bad" };
290
+ const bot = m.identity && m.identity.username ? `@${String(m.identity.username).replace(/^@/, "")}` : "";
291
+ switch (m.status) {
292
+ case "connected": return { key: bot ? "connConnectedAs" : "connected", cls: "ok", bot };
293
+ case "retrying": return { key: "connReconnecting", cls: "warn" };
294
+ case "starting": return { key: "connStarting", cls: "warn" };
295
+ case "failed": return { key: "connFailed", cls: "bad" };
296
+ default: return { key: "connNotRunning", cls: "off" };
297
+ }
298
+ }
@@ -100,7 +100,18 @@ register("settings", {
100
100
  tokenEnv: "Bot token env var",
101
101
  groupId: "Group / guild id",
102
102
  nameField: "Name",
103
- envHint: "Put the actual token in ~/.agend/.env under this env var, then Reload Fleet.",
103
+ tokenShow: "Show the token", tokenHide: "Hide the token", tokenIsBot: "This is {0}.", tokenStatus: "Bot token",
104
+ tokenSet: "Token set", tokenSetAs: "Token set · {0}", tokenMissing: "Token missing", tokenReplace: "Replace",
105
+ tokenStoredAs: "The token is stored on this computer in ~/.agend/.env under this name — generated, and only needed if you manage .env yourself.",
106
+ tokenSavedRestart: "Token saved. This connection is not running, so it starts with the new token when AgEnD restarts (on the host: agend restart).",
107
+ newBotTokenHint: "Paste the bot's token. It is checked with the platform, then stored on this computer — never in the browser.",
108
+ connectionIdOptional: "Connection id (optional)",
109
+ connConnectedAs: "Connected · {0}", connReconnecting: "Reconnecting…", connStarting: "Starting…", connFailed: "Could not connect",
110
+ connNotRunning: "Not running", connTokenMissing: "Token missing", connRejected: "Token rejected — replace it",
111
+ discordInvite: "Invite {0} to your server", discordTheBot: "the bot", discordWaitingJoin: "Waiting for the bot to join a server…",
112
+ discordCheckAgain: "Check again", discordNoServerYet: "Invite the bot first, or type the server id",
113
+ discordIntentNote: "Turn on Message Content Intent for this bot in the Discord developer portal (it cannot be set from here):", discordPortalBot: "Developer portal → Bot",
114
+ discordMissingIntent: "Turn on Message Content Intent in the Discord developer portal → Bot, then restart AgEnD.",
104
115
  noAgents: "No agents yet — click + New.",
105
116
  noBots: "No bots configured.",
106
117
  currentVersion: "Current version",
@@ -123,7 +134,7 @@ register("settings", {
123
134
  wizardButton: "Setup wizard",
124
135
  wizardTitle: "Set up AgEnD",
125
136
  wizardStep: "Step {0} of {1}",
126
- wizardRerun: "A fleet is already configured — finishing replaces the connection that uses the same token variable.",
137
+ wizardRerun: "A fleet is already configured — finishing adds a new connection; nothing already there is replaced.",
127
138
  wizardBack: "Back",
128
139
  wizardNext: "Next",
129
140
  wizardFinish: "Create and start",
@@ -131,6 +142,14 @@ register("settings", {
131
142
  wizardBackendFound: "Found on this host: {0}",
132
143
  wizardBackendNone: "No backend CLI found on this host — install one first.",
133
144
  wizardAgentName: "First agent name",
145
+ wizardAgentWhich: "Which agent answers on this bot",
146
+ wizardAgentNew: "A new agent",
147
+ wizardAgentExisting: "An agent you already have",
148
+ wizardAgentPick: "Agent",
149
+ wizardAgentExistingNote: "It keeps its directory, backend and settings; only its chat connection becomes this bot (if it has one now, it moves here).",
150
+ wizardAgentNewName: "New agent name",
151
+ wizardTopicNotCarried: "Its topic ({0}) is in the chat of its old connection, so it is not carried over: in the new chat it has no topic until one is bound.",
152
+ wizardTopicGeneral: "It is the General agent: in the new chat it takes that chat's General topic.",
134
153
  wizardNeedDir: "Enter an absolute working directory.",
135
154
  wizardTelegramHint: "Create a bot with @BotFather, then add it to a group.",
136
155
  wizardDiscordHint: "Create an application in the Discord developer portal and invite the bot to your server.",
@@ -165,7 +184,7 @@ register("settings", {
165
184
  restartFleetConfirm: "Restart AgEnD now?\n\nEvery agent stops and starts again, work in progress is interrupted, and the panel is unreachable for up to a minute. A notice is posted to your chat channel.",
166
185
  restartFleetStarted: "Restarting AgEnD — this page keeps checking.",
167
186
  restartFleetBusy: "Restarting…",
168
- restartFleetTitle: "Restarting AgEnD…",
187
+ restartFleetTitle: "Restarting AgEnD…", connStartingNow: "Starting the new connection — no restart needed…",
169
188
  restartFleetWaiting: "AgEnD is restarting. This page keeps checking and will say when it is back — there is nothing to press.",
170
189
  working: "Working…",
171
190
  restartFleetRefused: "Restart refused: {0}",
@@ -310,6 +329,7 @@ register("settings", {
310
329
  editMode: "Edit",
311
330
  viewMode: "View",
312
331
  copy: "Copy",
332
+ devDownload: "Download fleet.yaml",
313
333
  devEditor: "fleet.yaml editor",
314
334
  devApply: "Apply & save",
315
335
  devApplyHint: "Writes these settings and applies them, like Apply changes.",
@@ -424,7 +444,18 @@ register("settings", {
424
444
  tokenEnv: "Bot token 環境變數",
425
445
  groupId: "群組 / guild id",
426
446
  nameField: "名稱",
427
- envHint: "把實際 token 放到 ~/.agend/.env 的這個環境變數下,然後點重新載入。",
447
+ tokenShow: "顯示 token", tokenHide: "隱藏 token", tokenIsBot: "這是 {0}。", tokenStatus: "Bot token",
448
+ tokenSet: "已設定 token", tokenSetAs: "已設定 token · {0}", tokenMissing: "缺少 token", tokenReplace: "更換",
449
+ tokenStoredAs: "token 存在這台電腦的 ~/.agend/.env,用這個名稱——自動產生,只有自行管理 .env 時才需要。",
450
+ tokenSavedRestart: "已儲存 token。這個連線目前沒有在執行,AgEnD 重新啟動時會用新的 token 啟動(在主機上:agend restart)。",
451
+ newBotTokenHint: "貼上 bot 的 token。會先向平台確認,再存到這台電腦——絕不存在瀏覽器。",
452
+ connectionIdOptional: "連線 id(選填)",
453
+ connConnectedAs: "已連線 · {0}", connReconnecting: "重新連線中…", connStarting: "啟動中…", connFailed: "無法連線",
454
+ connNotRunning: "未執行", connTokenMissing: "缺少 token", connRejected: "token 被拒——請更換",
455
+ discordInvite: "邀請 {0} 加入你的伺服器", discordTheBot: "bot", discordWaitingJoin: "等待 bot 加入伺服器…",
456
+ discordCheckAgain: "再檢查一次", discordNoServerYet: "請先邀請 bot,或輸入伺服器 id",
457
+ discordIntentNote: "請在 Discord 開發者入口網站為這個 bot 開啟 Message Content Intent(這裡無法代為設定):", discordPortalBot: "開發者入口網站 → Bot",
458
+ discordMissingIntent: "請到 Discord 開發者入口網站 → Bot 開啟 Message Content Intent,然後重新啟動 AgEnD。",
428
459
  noAgents: "還沒有 agent — 點 + 新增。",
429
460
  noBots: "尚未設定機器人。",
430
461
  currentVersion: "目前版本",
@@ -447,7 +478,7 @@ register("settings", {
447
478
  wizardButton: "設定精靈",
448
479
  wizardTitle: "設定 AgEnD",
449
480
  wizardStep: "第 {0} 步,共 {1} 步",
450
- wizardRerun: "已經有設定好的 fleet —— 完成後會取代使用相同 token 變數的那個連線。",
481
+ wizardRerun: "已經有設定好的 fleet —— 完成後會新增一個連線,不會取代任何既有的設定。",
451
482
  wizardBack: "上一步",
452
483
  wizardNext: "下一步",
453
484
  wizardFinish: "建立並啟動",
@@ -455,6 +486,14 @@ register("settings", {
455
486
  wizardBackendFound: "在這台主機上找到:{0}",
456
487
  wizardBackendNone: "這台主機上找不到任何後端 CLI —— 請先安裝一個。",
457
488
  wizardAgentName: "第一個 agent 名稱",
489
+ wizardAgentWhich: "由哪個 agent 在這個機器人上回覆",
490
+ wizardAgentNew: "新的 agent",
491
+ wizardAgentExisting: "既有的 agent",
492
+ wizardAgentPick: "Agent",
493
+ wizardAgentExistingNote: "它的目錄、backend 和設定都不變;只有聊天連線改成這個機器人(如果它目前已有連線,會移到這裡)。",
494
+ wizardAgentNewName: "新 agent 名稱",
495
+ wizardTopicNotCarried: "它的 topic({0})屬於舊連線的聊天室,不會帶過去:在新的聊天室裡,綁定新的 topic 之前它沒有 topic。",
496
+ wizardTopicGeneral: "它是 General agent:在新的聊天室裡會改用那裡的 General topic。",
458
497
  wizardNeedDir: "請輸入絕對路徑的工作目錄。",
459
498
  wizardTelegramHint: "用 @BotFather 建立 bot,然後把它加進一個群組。",
460
499
  wizardDiscordHint: "在 Discord 開發者後台建立應用程式,並把 bot 邀請到你的伺服器。",
@@ -489,7 +528,7 @@ register("settings", {
489
528
  restartFleetConfirm: "現在重新啟動 AgEnD?\n\n所有 agent 都會停止再啟動,進行中的工作會被中斷,面板會有最多約一分鐘無法連線。聊天頻道會收到一則通知。",
490
529
  restartFleetStarted: "AgEnD 重新啟動中 —— 此頁面會持續確認。",
491
530
  restartFleetBusy: "重啟中…",
492
- restartFleetTitle: "AgEnD 重新啟動中…",
531
+ restartFleetTitle: "AgEnD 重新啟動中…", connStartingNow: "正在啟動新連線——不需要重新啟動…",
493
532
  restartFleetWaiting: "AgEnD 正在重新啟動。此頁面會持續確認,恢復後會告訴你 —— 不需要再按任何按鈕。",
494
533
  working: "執行中…",
495
534
  restartFleetRefused: "重新啟動被拒絕:{0}",
@@ -634,6 +673,7 @@ register("settings", {
634
673
  editMode: "編輯",
635
674
  viewMode: "檢視",
636
675
  copy: "複製",
676
+ devDownload: "下載 fleet.yaml",
637
677
  devEditor: "fleet.yaml 編輯器",
638
678
  devApply: "套用並儲存",
639
679
  devApplyHint: "寫入這些設定並套用,和「套用變更」相同。",
@@ -0,0 +1,54 @@
1
+ // #1519 P1 (docs/design/ux-onboarding-walkthrough.md §5.2): the one bot-token field — the setup wizard, "New
2
+ // connection" and a connection's "Replace token" use it. A password field (show/hide), Verify asks the platform who the
3
+ // token belongs to (Telegram getMe, Discord /users/@me) and shows the bot's name before anything is written. The token
4
+ // lives in this field's state only: never in storage, a URL, a label or the page's other state, and the server never
5
+ // hands it back — afterwards a connection shows "Token set · @bot".
6
+ import { html, useState } from "/assets/app-html.js";
7
+ import { t } from "/assets/app-i18n.js";
8
+ import { Icon } from "/assets/ui-icons.js";
9
+ import { api } from "./settings-confirm.js";
10
+
11
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
12
+
13
+ /** Who this token is: { valid, username?, reason? } — one outbound check, the token sent once and not kept. */
14
+ export async function verifyBotToken(platform, token) {
15
+ const res = await api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify({ action: "verify", platform, token }) })
16
+ .catch(() => ({ ok: false, body: {} }));
17
+ // A Discord bot's invite and portal page come with it (#1519 P3).
18
+ if (res.ok && res.body && res.body.identity) return { ...res.body.identity, invite: res.body.invite_url || null, portal: res.body.portal_url || null };
19
+ return { valid: false, reason: (res.body && res.body.error) || tn("verifyFailed") };
20
+ }
21
+
22
+ /** A bot's name as it is addressed: @name, once. */
23
+ export const botHandle = (username) => (username ? `@${String(username).replace(/^@/, "")}` : "");
24
+
25
+ /**
26
+ * The field. props: id, platform, value, onInput(token) (the parent drops its verified identity on a change and moves
27
+ * its credential revision, so a Verify still on its way for the old value cannot land — #1529 review),
28
+ * identity ({ valid, username, reason } | null), busy (the field is locked meanwhile), onVerify(), hint.
29
+ */
30
+ export function TokenField({ id, platform, value, onInput, identity, busy, onVerify, hint }) {
31
+ const [shown, setShown] = useState(false);
32
+ return html`<div class="field token-field"><label for=${id}>${tn("wizardToken")}</label>
33
+ <div class="token-row">
34
+ <input id=${id} type=${shown ? "text" : "password"} autocomplete="off" autocapitalize="off" spellcheck="false" disabled=${!!busy}
35
+ placeholder=${platform === "discord" ? "MTA…" : "123456:ABC-DEF…"} value=${value} onInput=${(e) => onInput(e.target.value.trim())} />
36
+ <button type="button" class="icon-btn" aria-pressed=${shown ? "true" : "false"} title=${shown ? tn("tokenHide") : tn("tokenShow")}
37
+ aria-label=${shown ? tn("tokenHide") : tn("tokenShow")} onClick=${() => setShown(!shown)}><${Icon} name="eye" size=${16} /></button>
38
+ </div>
39
+ <p class="note">${hint || (platform === "telegram" ? tn("wizardTokenTelegram") : tn("wizardTokenDiscord"))}</p>
40
+ <div class="dlg-inline-actions"><button type="button" class="btn btn-sm" disabled=${busy || !value} onClick=${onVerify}>${tn("wizardVerify")}</button>
41
+ ${identity ? html`<span class=${`feedback${identity.valid ? " ok" : " error"}`} role="status">${identity.valid
42
+ ? tn("tokenIsBot", botHandle(identity.username) || tn("verified")) : (identity.reason || tn("verifyFailed"))}</span>` : null}</div>
43
+ </div>`;
44
+ }
45
+
46
+ /** The generated env var a token is stored under, read-only with a copy button (the "Advanced" view). */
47
+ export function TokenEnvNote({ name }) {
48
+ const [copied, setCopied] = useState(false);
49
+ if (!name) return null;
50
+ const copy = async () => { try { await navigator.clipboard.writeText(name); setCopied(true); } catch { /* the name is on screen */ } };
51
+ return html`<div class="field"><span>${tn("tokenEnv")}</span>
52
+ <div class="dlg-inline-actions"><code>${name}</code><button type="button" class="btn btn-sm btn-ghost" onClick=${copy}>${copied ? tn("copied") : tn("copy")}</button></div>
53
+ <p class="note">${tn("tokenStoredAs")}</p></div>`;
54
+ }