talon-agent 3.33.4 → 3.34.1

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/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `effort:select` — the standalone /effort select menu.
3
+ */
4
+
5
+ import type { StringSelectMenuInteraction } from "discord.js";
6
+ import {
7
+ setChatEffort,
8
+ type EffortLevel,
9
+ } from "../../../../storage/chat-settings.js";
10
+ import {
11
+ getBackendIdForChat,
12
+ resolveChatBackend,
13
+ } from "../../../../core/engine/backend-controller/index.js";
14
+ import {
15
+ getActiveReasoningLevels,
16
+ supportsReasoningLevel,
17
+ } from "../../../shared/reasoning-levels.js";
18
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
19
+
20
+ async function selectEffort(
21
+ interaction: StringSelectMenuInteraction,
22
+ { config, gateway, chatId }: ComponentContext,
23
+ ): Promise<void> {
24
+ const level = interaction.values[0];
25
+ const be = resolveChatBackend(chatId, gateway?.backend);
26
+ const beId = getBackendIdForChat(chatId);
27
+ const reasoning = await getActiveReasoningLevels({
28
+ chatId,
29
+ backend: be,
30
+ backendId: beId,
31
+ config,
32
+ });
33
+ if (reasoning.levels.length === 0) {
34
+ await interaction.update({
35
+ content: "No valid reasoning levels found for this model.",
36
+ components: [],
37
+ });
38
+ return;
39
+ }
40
+ if (level === "adaptive") setChatEffort(chatId, undefined);
41
+ else if (supportsReasoningLevel(level, reasoning.levels))
42
+ setChatEffort(chatId, level as EffortLevel);
43
+ else {
44
+ await interaction.update({
45
+ content: "Invalid reasoning level for this model.",
46
+ components: [],
47
+ });
48
+ return;
49
+ }
50
+ try {
51
+ await interaction.update({
52
+ content: `**Effort:** ${level}`,
53
+ components: [],
54
+ });
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+
60
+ export async function handleEffortComponent(
61
+ interaction: ComponentInteraction,
62
+ context: ComponentContext,
63
+ ): Promise<boolean> {
64
+ if (
65
+ interaction.customId === "effort:select" &&
66
+ interaction.isStringSelectMenu()
67
+ ) {
68
+ await selectEffort(interaction, context);
69
+ return true;
70
+ }
71
+ return false;
72
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Component (button + select menu) interaction router.
3
+ *
4
+ * Custom ids are namespaced by a `:`-terminated prefix; the table below maps
5
+ * each prefix to the module that owns it. A handler returns `false` for an
6
+ * id under its prefix it doesn't recognise (a stale button from an older
7
+ * panel, a select-menu id arriving from a button), and the router acks
8
+ * those exactly as it acks a prefix with no handler.
9
+ *
10
+ * Custom-id namespace:
11
+ * settings:done
12
+ * settings:model (select menu, value=modelId)
13
+ * settings:effort:select (select menu)
14
+ * settings:proactive:on|off
15
+ * pulse:on | pulse:off | pulse:interval (button → opens modal)
16
+ * effort:select (select menu, standalone /effort)
17
+ * model:select (select menu, standalone /model)
18
+ * model:nav:* (pager)
19
+ * model:backend-select (select menu)
20
+ * metrics:today | metrics:all
21
+ * ai:<id> (AI-generated buttons — forwarded to agent)
22
+ */
23
+
24
+ import { MessageFlags } from "discord.js";
25
+ import type { TalonConfig } from "../../../../util/config.js";
26
+ import type { Gateway } from "../../../../core/engine/gateway.js";
27
+ import { deriveNumericChatId } from "../../../../util/chat-id.js";
28
+ import { logError } from "../../../../util/log.js";
29
+ import {
30
+ isInteractionAllowed,
31
+ registerDiscordChat,
32
+ } from "../../handlers/index.js";
33
+ import { forwardToAgent } from "./agent-buttons.js";
34
+ import { handleEffortComponent } from "./effort.js";
35
+ import { handleMetricsComponent } from "./metrics.js";
36
+ import { handleModelComponent } from "./model.js";
37
+ import { handlePulseComponent } from "./pulse.js";
38
+ import { handleSettingsComponent } from "./settings.js";
39
+ import type { ComponentHandlers, ComponentInteraction } from "./types.js";
40
+
41
+ // Null-prototype so a custom id of "toString:" / "constructor:" / etc.
42
+ // can't resolve an inherited Object.prototype method via `handlers[key]`.
43
+ export const COMPONENT_HANDLERS: ComponentHandlers = Object.assign(
44
+ Object.create(null),
45
+ {
46
+ "settings:": handleSettingsComponent,
47
+ "pulse:": handlePulseComponent,
48
+ "effort:": handleEffortComponent,
49
+ "model:": handleModelComponent,
50
+ "metrics:": handleMetricsComponent,
51
+ "ai:": forwardToAgent,
52
+ } satisfies ComponentHandlers,
53
+ );
54
+
55
+ /**
56
+ * The table key for a custom id: its first segment, colon included
57
+ * (`"settings:done"` → `"settings:"`). An id with no colon keys to `""`,
58
+ * which no handler claims.
59
+ */
60
+ export function componentRouteKey(customId: string): string {
61
+ return customId.slice(0, customId.indexOf(":") + 1);
62
+ }
63
+
64
+ function chatIdFromInteraction(interaction: ComponentInteraction): {
65
+ chatId: string;
66
+ numericChatId: number;
67
+ } {
68
+ const chatId = interaction.guildId
69
+ ? `discord_guild_${interaction.guildId}_${interaction.channelId}`
70
+ : `discord_dm_${interaction.user.id}`;
71
+ return { chatId, numericChatId: deriveNumericChatId(chatId) };
72
+ }
73
+
74
+ export async function handleComponentInteraction(
75
+ interaction: ComponentInteraction,
76
+ config: TalonConfig,
77
+ gateway: Gateway,
78
+ ): Promise<void> {
79
+ // Access control
80
+ const access = isInteractionAllowed(
81
+ interaction.inGuild(),
82
+ interaction.user.id,
83
+ interaction.guildId,
84
+ interaction.channelId,
85
+ );
86
+ if (!access.ok) {
87
+ await interaction.reply({
88
+ content: `⚠️ ${access.reason}`,
89
+ flags: MessageFlags.Ephemeral,
90
+ });
91
+ return;
92
+ }
93
+
94
+ const { chatId, numericChatId } = chatIdFromInteraction(interaction);
95
+ registerDiscordChat({
96
+ channelId: interaction.channelId!,
97
+ guildId: interaction.guildId,
98
+ userId: interaction.guildId ? null : interaction.user.id,
99
+ numericChatId,
100
+ chatId,
101
+ });
102
+
103
+ const customId = interaction.customId;
104
+ const handler = COMPONENT_HANDLERS[componentRouteKey(customId)];
105
+ if (
106
+ handler &&
107
+ (await handler(interaction, { config, gateway, chatId, numericChatId }))
108
+ ) {
109
+ return;
110
+ }
111
+
112
+ // Unknown custom_id — log + silently ack so Discord doesn't show
113
+ // "Interaction failed". This catches stale buttons from older panels.
114
+ logError("discord", `Unknown custom_id: ${customId}`);
115
+ try {
116
+ await interaction.deferUpdate();
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `metrics:today` | `metrics:all` — the /metrics today ↔ all-time toggle.
3
+ */
4
+
5
+ import { metricsViewRow, renderMetricsView } from "../../commands/admin.js";
6
+ import type { ComponentInteraction } from "./types.js";
7
+
8
+ export async function handleMetricsComponent(
9
+ interaction: ComponentInteraction,
10
+ ): Promise<boolean> {
11
+ const customId = interaction.customId;
12
+ if (customId !== "metrics:today" && customId !== "metrics:all") return false;
13
+ const view = customId === "metrics:all" ? "all" : "today";
14
+ const messages = renderMetricsView(view);
15
+ try {
16
+ await interaction.update({
17
+ content: messages[0]!,
18
+ components: [metricsViewRow(view).toJSON()],
19
+ });
20
+ } catch {
21
+ /* ignore */
22
+ }
23
+ return true;
24
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `model:nav:*` — the /model pager: page arrows, free-tier toggle, provider
3
+ * drill, and the backend submenu. Pure navigation: re-renders the same
4
+ * panel at the requested page without touching the chat's selected model.
5
+ */
6
+
7
+ import {
8
+ buildModelPickerView,
9
+ buildBackendPickerView,
10
+ decodeModelNav,
11
+ MODEL_NAV_PREFIX,
12
+ MODEL_PAGE_SIZE,
13
+ } from "../../model-picker.js";
14
+ import {
15
+ getBackendIdForChat,
16
+ resolveChatBackend,
17
+ listAvailableBackends,
18
+ } from "../../../../core/engine/backend-controller/index.js";
19
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
20
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
21
+
22
+ export async function handleModelNav(
23
+ interaction: ComponentInteraction,
24
+ { config, gateway, chatId }: ComponentContext,
25
+ ): Promise<void> {
26
+ const customId = interaction.customId;
27
+ // Backend submenu — the one nav action that isn't a re-page.
28
+ if (customId === `${MODEL_NAV_PREFIX}:backends`) {
29
+ const view = buildBackendPickerView(
30
+ listAvailableBackends(config),
31
+ getBackendIdForChat(chatId),
32
+ );
33
+ try {
34
+ await interaction.update({
35
+ content: view.content,
36
+ components: view.components,
37
+ });
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ return;
42
+ }
43
+
44
+ const target = decodeModelNav(customId);
45
+ if (!target) {
46
+ try {
47
+ await interaction.deferUpdate();
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ return;
52
+ }
53
+ const be = resolveChatBackend(chatId, gateway?.backend);
54
+ const beId = getBackendIdForChat(chatId);
55
+ const { model: current } = await resolveActiveModelForChat(
56
+ chatId,
57
+ be,
58
+ beId,
59
+ config,
60
+ );
61
+ if (!be?.models?.getSettingsPresentation) {
62
+ try {
63
+ await interaction.deferUpdate();
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ return;
68
+ }
69
+ const pres = await be.models.getSettingsPresentation(current ?? "", {
70
+ callbackPrefix: "model:",
71
+ navCallbackPrefix: MODEL_NAV_PREFIX,
72
+ pageSize: MODEL_PAGE_SIZE,
73
+ page: target.page,
74
+ filter: target.filter,
75
+ ...(target.provider ? { provider: target.provider } : {}),
76
+ });
77
+ const modelInfo = current
78
+ ? await be.models?.getRawModelInfo?.(current)
79
+ : undefined;
80
+ const view = buildModelPickerView(
81
+ pres,
82
+ modelInfo?.displayName ?? current ?? "_No model selected_",
83
+ beId,
84
+ );
85
+ try {
86
+ await interaction.update({
87
+ content: view.content,
88
+ components: view.components,
89
+ });
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `model:select` — the /model select menu (separate from settings).
3
+ */
4
+
5
+ import { type StringSelectMenuInteraction, MessageFlags } from "discord.js";
6
+ import {
7
+ buildModelPickerView,
8
+ MODEL_NAV_PREFIX,
9
+ MODEL_PAGE_SIZE,
10
+ } from "../../model-picker.js";
11
+ import {
12
+ setChatModelForBackend,
13
+ setChatBackend,
14
+ } from "../../../../storage/chat-settings.js";
15
+ import { resolveModelId as resolveModelName } from "../../../../core/models/catalog.js";
16
+ import {
17
+ getBackendIdForChat,
18
+ resolveChatBackend,
19
+ } from "../../../../core/engine/backend-controller/index.js";
20
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
21
+ import type { ComponentContext } from "./types.js";
22
+
23
+ export async function handleModelSelect(
24
+ interaction: StringSelectMenuInteraction,
25
+ { config, gateway, chatId }: ComponentContext,
26
+ ): Promise<void> {
27
+ const value = interaction.values[0];
28
+ // Resolve and snapshot against the per-chat backend — never the
29
+ // global default — so this works in channels with a backend
30
+ // override pinned (e.g. switched to openai-agents to use
31
+ // OpenRouter models).
32
+ const be = resolveChatBackend(chatId, gateway?.backend);
33
+ const beId = getBackendIdForChat(chatId);
34
+
35
+ // In the provider view the select carries chips, not models — picking
36
+ // one drills into that provider rather than choosing anything.
37
+ if (value?.startsWith("provider:") && be?.models?.getSettingsPresentation) {
38
+ const providerId = value.slice("provider:".length);
39
+ const { model: activeNow } = await resolveActiveModelForChat(
40
+ chatId,
41
+ be,
42
+ beId,
43
+ config,
44
+ );
45
+ const pres = await be.models.getSettingsPresentation(activeNow ?? "", {
46
+ callbackPrefix: "model:",
47
+ navCallbackPrefix: MODEL_NAV_PREFIX,
48
+ pageSize: MODEL_PAGE_SIZE,
49
+ provider: providerId,
50
+ });
51
+ const view = buildModelPickerView(
52
+ pres,
53
+ activeNow ?? "_No model selected_",
54
+ beId,
55
+ );
56
+ try {
57
+ await interaction.update({
58
+ content: view.content,
59
+ components: view.components,
60
+ });
61
+ } catch {
62
+ /* ignore */
63
+ }
64
+ return;
65
+ }
66
+
67
+ if (value === "reset") {
68
+ // Clear THIS backend's slot only — other backends stay intact.
69
+ setChatModelForBackend(chatId, beId, undefined);
70
+ } else if (be?.models?.resolveModelInfo) {
71
+ const resolution = await be.models?.resolveModelInfo(value);
72
+ if (resolution.kind === "exact" && resolution.model.selectable) {
73
+ setChatModelForBackend(chatId, beId, resolution.storedValue);
74
+ setChatBackend(chatId, beId);
75
+ } else {
76
+ await interaction.reply({
77
+ content: "Model unavailable.",
78
+ flags: MessageFlags.Ephemeral,
79
+ });
80
+ return;
81
+ }
82
+ } else {
83
+ setChatModelForBackend(chatId, beId, resolveModelName(value));
84
+ setChatBackend(chatId, beId);
85
+ }
86
+
87
+ // Resolve display through the active-model helper so the panel
88
+ // matches what queries will actually run. Null surfaces as
89
+ // "No model selected".
90
+ const { model: resolvedCurrent } = await resolveActiveModelForChat(
91
+ chatId,
92
+ be,
93
+ beId,
94
+ config,
95
+ );
96
+ if (be?.models?.getSettingsPresentation) {
97
+ const current = resolvedCurrent ?? "";
98
+ const pres = await be.models?.getSettingsPresentation(current, {
99
+ callbackPrefix: "model:",
100
+ navCallbackPrefix: MODEL_NAV_PREFIX,
101
+ pageSize: MODEL_PAGE_SIZE,
102
+ });
103
+ const modelInfo = resolvedCurrent
104
+ ? await be.models?.getRawModelInfo?.(resolvedCurrent)
105
+ : undefined;
106
+ const displayName =
107
+ modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
108
+ const view = buildModelPickerView(pres, displayName, beId);
109
+ try {
110
+ await interaction.update({
111
+ content: view.content,
112
+ components: view.components,
113
+ });
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `model:*` — the /model panel, three ids under one prefix:
3
+ *
4
+ * model:select (select menu → model-select.ts)
5
+ * model:nav:* (pager buttons → model-nav.ts)
6
+ * model:backend-select (select menu → backend-select.ts)
7
+ */
8
+
9
+ import { MODEL_NAV_PREFIX } from "../../model-picker.js";
10
+ import { handleBackendSelect } from "./backend-select.js";
11
+ import { handleModelNav } from "./model-nav.js";
12
+ import { handleModelSelect } from "./model-select.js";
13
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
14
+
15
+ export async function handleModelComponent(
16
+ interaction: ComponentInteraction,
17
+ context: ComponentContext,
18
+ ): Promise<boolean> {
19
+ const customId = interaction.customId;
20
+ if (customId === "model:select" && interaction.isStringSelectMenu()) {
21
+ await handleModelSelect(interaction, context);
22
+ return true;
23
+ }
24
+ if (customId.startsWith(`${MODEL_NAV_PREFIX}:`)) {
25
+ await handleModelNav(interaction, context);
26
+ return true;
27
+ }
28
+ if (customId === "model:backend-select" && interaction.isStringSelectMenu()) {
29
+ await handleBackendSelect(interaction, context);
30
+ return true;
31
+ }
32
+ return false;
33
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `pulse:*` — the standalone /pulse panel.
3
+ *
4
+ * pulse:interval (button → opens the interval modal)
5
+ * pulse:on | pulse:off
6
+ */
7
+
8
+ import {
9
+ type ButtonInteraction,
10
+ ActionRowBuilder,
11
+ ButtonBuilder,
12
+ ButtonStyle,
13
+ ModalBuilder,
14
+ TextInputBuilder,
15
+ TextInputStyle,
16
+ } from "discord.js";
17
+ import {
18
+ registerChat,
19
+ disablePulse,
20
+ enablePulse,
21
+ isPulseEnabled,
22
+ } from "../../../../core/background/pulse.js";
23
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
24
+
25
+ // ── /pulse "Set interval…" button → opens modal ───────────────────────
26
+ async function openIntervalModal(
27
+ interaction: ButtonInteraction,
28
+ ): Promise<void> {
29
+ const modal = new ModalBuilder()
30
+ .setCustomId("modal:pulse-interval")
31
+ .setTitle("Pulse interval");
32
+ const input = new TextInputBuilder()
33
+ .setCustomId("interval")
34
+ .setLabel("How often (min 5m)")
35
+ .setPlaceholder("30m, 2h, 90m, 1d…")
36
+ .setStyle(TextInputStyle.Short)
37
+ .setMinLength(2)
38
+ .setMaxLength(16)
39
+ .setRequired(true);
40
+ modal.addComponents(
41
+ new ActionRowBuilder<TextInputBuilder>().addComponents(input),
42
+ );
43
+ await interaction.showModal(modal);
44
+ }
45
+
46
+ // ── Standalone /pulse on|off buttons ──────────────────────────────────────
47
+ async function togglePulse(
48
+ interaction: ComponentInteraction,
49
+ chatId: string,
50
+ ): Promise<void> {
51
+ const val = interaction.customId.slice(6);
52
+ if (val === "on") {
53
+ enablePulse(chatId);
54
+ registerChat(chatId);
55
+ } else if (val === "off") {
56
+ disablePulse(chatId);
57
+ }
58
+ const enabled = isPulseEnabled(chatId);
59
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
60
+ new ButtonBuilder()
61
+ .setCustomId("pulse:on")
62
+ .setLabel(enabled ? "✓ On" : "On")
63
+ .setStyle(enabled ? ButtonStyle.Success : ButtonStyle.Secondary),
64
+ new ButtonBuilder()
65
+ .setCustomId("pulse:off")
66
+ .setLabel(!enabled ? "✓ Off" : "Off")
67
+ .setStyle(ButtonStyle.Secondary),
68
+ );
69
+ try {
70
+ await (interaction as ButtonInteraction).update({
71
+ content: [
72
+ `**🔔 Pulse:** ${enabled ? "on" : "off"}`,
73
+ "",
74
+ "Reads along every few minutes and jumps in when there's something to add.",
75
+ ].join("\n"),
76
+ components: [row],
77
+ });
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
82
+
83
+ export async function handlePulseComponent(
84
+ interaction: ComponentInteraction,
85
+ { chatId }: ComponentContext,
86
+ ): Promise<boolean> {
87
+ if (interaction.customId === "pulse:interval" && interaction.isButton()) {
88
+ await openIntervalModal(interaction);
89
+ return true;
90
+ }
91
+ await togglePulse(interaction, chatId);
92
+ return true;
93
+ }