talon-agent 3.33.4 → 3.34.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 (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -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
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `settings:*` — the /settings panel. Every callback updates the original
3
+ * message in place (Discord supports update() on the interaction).
4
+ *
5
+ * settings:done
6
+ * settings:model (select menu, value=modelId)
7
+ * settings:effort:select (select menu)
8
+ * settings:proactive:on|off
9
+ */
10
+
11
+ import {
12
+ ActionRowBuilder,
13
+ ButtonBuilder,
14
+ ButtonStyle,
15
+ StringSelectMenuBuilder,
16
+ MessageFlags,
17
+ } from "discord.js";
18
+ import type { TalonConfig } from "../../../../util/config.js";
19
+ import type { Gateway } from "../../../../core/engine/gateway.js";
20
+ import {
21
+ getChatSettings,
22
+ setChatModelForBackend,
23
+ setChatBackend,
24
+ setChatEffort,
25
+ type EffortLevel,
26
+ } from "../../../../storage/chat-settings.js";
27
+ import { resolveModelId as resolveModelName } from "../../../../core/models/catalog.js";
28
+ import {
29
+ registerChat,
30
+ disablePulse,
31
+ enablePulse,
32
+ isPulseEnabled,
33
+ } from "../../../../core/background/pulse.js";
34
+ import { renderSettingsText, EFFORT_DESCRIPTIONS } from "../../helpers.js";
35
+ import {
36
+ getBackendIdForChat,
37
+ resolveChatBackend,
38
+ } from "../../../../core/engine/backend-controller/index.js";
39
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
40
+ import {
41
+ displayReasoningEffort,
42
+ getActiveReasoningLevels,
43
+ supportsReasoningLevel,
44
+ } from "../../../shared/reasoning-levels.js";
45
+ import { logError } from "../../../../util/log.js";
46
+ import { safeSlice } from "../../formatting.js";
47
+ import type { ComponentContext, ComponentInteraction } from "./types.js";
48
+
49
+ export async function handleSettingsComponent(
50
+ interaction: ComponentInteraction,
51
+ { config, gateway, chatId }: ComponentContext,
52
+ ): Promise<boolean> {
53
+ const parts = interaction.customId.split(":");
54
+ const category = parts[1];
55
+
56
+ if (category === "done") {
57
+ try {
58
+ await interaction.update({ components: [], content: "Done." });
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ return true;
63
+ }
64
+
65
+ if (category === "model" && interaction.isStringSelectMenu()) {
66
+ const value = interaction.values[0];
67
+ const settingsBe = resolveChatBackend(chatId, gateway?.backend);
68
+ const settingsBeId = getBackendIdForChat(chatId);
69
+ if (value === "reset") {
70
+ // Clear THIS backend's slot only — other backends' picks stay.
71
+ setChatModelForBackend(chatId, settingsBeId, undefined);
72
+ } else if (settingsBe?.models?.resolveModelInfo) {
73
+ const resolution = await settingsBe.models?.resolveModelInfo(value);
74
+ if (resolution.kind !== "exact" || !resolution.model.selectable) {
75
+ await interaction.reply({
76
+ content: `Model unavailable.`,
77
+ flags: MessageFlags.Ephemeral,
78
+ });
79
+ return true;
80
+ }
81
+ setChatModelForBackend(chatId, settingsBeId, resolution.storedValue);
82
+ setChatBackend(chatId, settingsBeId);
83
+ } else {
84
+ setChatModelForBackend(chatId, settingsBeId, resolveModelName(value));
85
+ setChatBackend(chatId, settingsBeId);
86
+ }
87
+ await refreshSettingsPanel(interaction, config, gateway, chatId);
88
+ return true;
89
+ }
90
+
91
+ if (category === "effort" && interaction.isStringSelectMenu()) {
92
+ // settings:effort:select — current shape (emitted by handleSettings +
93
+ // refreshSettingsPanel).
94
+ const level = interaction.values[0];
95
+ const settingsBe = resolveChatBackend(chatId, gateway?.backend);
96
+ const settingsBeId = getBackendIdForChat(chatId);
97
+ const reasoning = await getActiveReasoningLevels({
98
+ chatId,
99
+ backend: settingsBe,
100
+ backendId: settingsBeId,
101
+ config,
102
+ });
103
+ if (level === "adaptive") {
104
+ setChatEffort(chatId, undefined);
105
+ } else if (supportsReasoningLevel(level, reasoning.levels)) {
106
+ setChatEffort(chatId, level as EffortLevel);
107
+ } else {
108
+ await interaction.reply({
109
+ content: "No valid reasoning level for this model.",
110
+ flags: MessageFlags.Ephemeral,
111
+ });
112
+ return true;
113
+ }
114
+ await refreshSettingsPanel(interaction, config, gateway, chatId);
115
+ return true;
116
+ }
117
+
118
+ if (category === "proactive") {
119
+ const value = parts[2] ?? "";
120
+ if (value === "on") {
121
+ enablePulse(chatId);
122
+ registerChat(chatId);
123
+ } else {
124
+ disablePulse(chatId);
125
+ }
126
+ await refreshSettingsPanel(interaction, config, gateway, chatId);
127
+ return true;
128
+ }
129
+
130
+ return false;
131
+ }
132
+
133
+ async function refreshSettingsPanel(
134
+ interaction: ComponentInteraction,
135
+ config: TalonConfig,
136
+ gateway: Gateway,
137
+ chatId: string,
138
+ ): Promise<void> {
139
+ const chatSets = getChatSettings(chatId);
140
+ const settingsBe = resolveChatBackend(chatId, gateway?.backend);
141
+ const settingsBeId = getBackendIdForChat(chatId);
142
+ // Match the Telegram path — resolve via the 5-step chain so the
143
+ // panel's "Model:" line stays coherent on backend-overridden
144
+ // chats and surfaces "No model selected" when applicable.
145
+ const { model: resolvedActive } = await resolveActiveModelForChat(
146
+ chatId,
147
+ settingsBe,
148
+ settingsBeId,
149
+ config,
150
+ );
151
+ const activeModel = resolvedActive ?? "No model selected";
152
+ const pulseOn = isPulseEnabled(chatId);
153
+ const reasoning = await getActiveReasoningLevels({
154
+ chatId,
155
+ backend: settingsBe,
156
+ backendId: settingsBeId,
157
+ config,
158
+ });
159
+ const effortName = displayReasoningEffort(chatSets.effort, reasoning.levels);
160
+
161
+ let modelDetails: Array<string> | undefined;
162
+ let modelButtons: Array<{ text: string; callback_data: string }> | undefined;
163
+ // `settingsBe` already resolved above for the activeModel lookup —
164
+ // reuse it instead of re-resolving (it points at the per-chat
165
+ // backend, override-aware).
166
+ if (settingsBe?.models?.getSettingsPresentation) {
167
+ const presModelId = resolvedActive ?? "";
168
+ const pres = await settingsBe.models?.getSettingsPresentation(presModelId);
169
+ modelDetails = pres.modelDetails;
170
+ modelButtons = pres.modelButtons;
171
+ }
172
+
173
+ const components: ActionRowBuilder<
174
+ StringSelectMenuBuilder | ButtonBuilder
175
+ >[] = [];
176
+
177
+ if (modelButtons?.length) {
178
+ const menu = new StringSelectMenuBuilder()
179
+ .setCustomId("settings:model")
180
+ .setPlaceholder("Select a model")
181
+ .addOptions(
182
+ modelButtons.slice(0, 25).map((b) => ({
183
+ label: safeSlice(b.text.replace(/^✓ /, ""), 100),
184
+ value: safeSlice(
185
+ b.callback_data.replace(/^settings:model:/, ""),
186
+ 100,
187
+ ),
188
+ default: b.text.startsWith("✓"),
189
+ })),
190
+ );
191
+ components.push(
192
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu),
193
+ );
194
+ }
195
+
196
+ if (reasoning.levels.length > 0) {
197
+ const effortMenu = new StringSelectMenuBuilder()
198
+ .setCustomId("settings:effort:select")
199
+ .setPlaceholder(`Effort: ${effortName}`)
200
+ .addOptions(
201
+ [...reasoning.levels, "adaptive"].map((v) => ({
202
+ label: v,
203
+ value: v,
204
+ description: EFFORT_DESCRIPTIONS[v],
205
+ default: effortName === v,
206
+ })),
207
+ );
208
+ components.push(
209
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(effortMenu),
210
+ );
211
+ }
212
+
213
+ components.push(
214
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
215
+ new ButtonBuilder()
216
+ .setCustomId(`settings:proactive:${pulseOn ? "off" : "on"}`)
217
+ .setLabel(pulseOn ? "Pulse: ON" : "Pulse: OFF")
218
+ .setStyle(pulseOn ? ButtonStyle.Success : ButtonStyle.Secondary),
219
+ new ButtonBuilder()
220
+ .setCustomId("settings:done")
221
+ .setLabel("Done")
222
+ .setStyle(ButtonStyle.Secondary),
223
+ ),
224
+ );
225
+
226
+ try {
227
+ await interaction.update({
228
+ content: renderSettingsText(
229
+ activeModel,
230
+ effortName,
231
+ pulseOn,
232
+ chatSets.pulseIntervalMs,
233
+ modelDetails,
234
+ ),
235
+ components,
236
+ });
237
+ } catch (err) {
238
+ logError(
239
+ "discord",
240
+ `Failed to refresh settings panel: ${err instanceof Error ? err.message : err}`,
241
+ );
242
+ }
243
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Component-handler types.
3
+ *
4
+ * The router (index.ts) keys `ComponentHandlers` by the custom-id prefix —
5
+ * the first `:`-terminated segment (`settings:`, `pulse:`, `model:`, …).
6
+ * A handler owns everything under its prefix and returns `false` for an id
7
+ * it doesn't recognise, so the router can ack it the way it acks an id with
8
+ * no handler at all (stale buttons from older panels).
9
+ */
10
+
11
+ import type {
12
+ ButtonInteraction,
13
+ StringSelectMenuInteraction,
14
+ } from "discord.js";
15
+ import type { TalonConfig } from "../../../../util/config.js";
16
+ import type { Gateway } from "../../../../core/engine/gateway.js";
17
+
18
+ export type ComponentInteraction =
19
+ ButtonInteraction | StringSelectMenuInteraction;
20
+
21
+ export interface ComponentContext {
22
+ config: TalonConfig;
23
+ gateway: Gateway;
24
+ /** Talon chat id (`discord_guild_…` / `discord_dm_…`), already registered. */
25
+ chatId: string;
26
+ numericChatId: number;
27
+ }
28
+
29
+ type ComponentHandler = (
30
+ interaction: ComponentInteraction,
31
+ context: ComponentContext,
32
+ ) => Promise<boolean>;
33
+
34
+ export type ComponentHandlers = Record<string, ComponentHandler>;
@@ -2,13 +2,13 @@
2
2
  * Discord interaction callbacks — components, modals, and autocomplete.
3
3
  *
4
4
  * Mirrors src/frontend/telegram/callbacks. Split by interaction kind:
5
- * - shared — ComponentInteraction type + chatIdFromInteraction
6
- * - components — button + select-menu handler (settings/pulse/effort/model
7
- * + ai: forwarding), refreshSettingsPanel, forwardToAgent
5
+ * - components/ — button + select-menu router, a dispatch table keyed by
6
+ * custom-id prefix with one handler module per prefix
7
+ * (settings/pulse/effort/model/metrics + ai: forwarding)
8
8
  * - modals — modal submissions (pulse interval)
9
9
  * - autocomplete — /model name autocomplete with a cached model list
10
10
  */
11
11
 
12
- export { handleComponentInteraction } from "./components.js";
12
+ export { handleComponentInteraction } from "./components/index.js";
13
13
  export { handleModalSubmit } from "./modals.js";
14
14
  export { handleAutocomplete } from "./autocomplete.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Client connection lifecycle — login (blocking until READY) and the
3
+ * shutdown sequence that takes the gateway down with the client.
4
+ */
5
+
6
+ import { Events } from "discord.js";
7
+ import { once } from "node:events";
8
+ import { log, logError } from "../../util/log.js";
9
+ import type { DiscordRuntime } from "./runtime.js";
10
+
11
+ export async function connect(runtime: DiscordRuntime): Promise<void> {
12
+ const { client, discord: dc } = runtime;
13
+ await client.login(dc.botToken);
14
+ // Wait for the WebSocket READY event before returning — login() resolves
15
+ // after HTTP IDENTIFY but client.user / client.guilds.cache are empty
16
+ // until READY fires. Anything called after start() can then safely read
17
+ // gateway state.
18
+ if (!client.isReady()) {
19
+ await once(client, Events.ClientReady);
20
+ }
21
+ }
22
+
23
+ export async function disconnect(runtime: DiscordRuntime): Promise<void> {
24
+ try {
25
+ await runtime.client.destroy();
26
+ log("shutdown", "Discord client disconnected");
27
+ } catch (err) {
28
+ logError("shutdown", "Discord stop error", err);
29
+ }
30
+ try {
31
+ await runtime.gateway.stop();
32
+ log("shutdown", "Gateway stopped");
33
+ } catch (err) {
34
+ logError("shutdown", "Gateway stop error", err);
35
+ }
36
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Client diagnostics — the error/warn/disconnect/rate-limit listeners that
3
+ * only log. discord.js handles reconnects and retries itself; these exist
4
+ * so a silent failure mode (a fatal close code, an approaching IP ban) is
5
+ * visible in the daemon log.
6
+ */
7
+
8
+ import { type Client, Events } from "discord.js";
9
+ import { logError, logWarn } from "../../util/log.js";
10
+
11
+ function shardDisconnectLabel(code: number | undefined): string {
12
+ return code === 4004
13
+ ? "AUTHENTICATION_FAILED — bot token invalid/revoked"
14
+ : code === 4013
15
+ ? "INVALID_INTENTS — declared intent doesn't exist"
16
+ : code === 4014
17
+ ? "DISALLOWED_INTENTS — privileged intent (GuildMembers/MessageContent/Presence) not enabled in Developer Portal"
18
+ : `code=${code}`;
19
+ }
20
+
21
+ export function bindClientDiagnostics(client: Client): void {
22
+ client.on("error", (err) => {
23
+ logError("discord", "Client error", err);
24
+ });
25
+ client.on("warn", (msg) => {
26
+ logWarn("discord", msg);
27
+ });
28
+
29
+ // Surface gateway close codes — discord.js auto-reconnects on most, but
30
+ // 4004 (auth failed), 4013 (invalid intents), 4014 (disallowed intents)
31
+ // are terminal: the bot will hang silent in IDENTIFY without an alert.
32
+ client.on(Events.ShardDisconnect, (event, shardId) => {
33
+ const code = event?.code;
34
+ const reason = event?.reason || "(no reason)";
35
+ const fatal = code === 4004 || code === 4013 || code === 4014;
36
+ const label = shardDisconnectLabel(code);
37
+ if (fatal) {
38
+ logError(
39
+ "discord",
40
+ `Shard ${shardId} disconnected fatally: ${label} (${reason})`,
41
+ );
42
+ } else {
43
+ logWarn("discord", `Shard ${shardId} disconnected: ${label} (${reason})`);
44
+ }
45
+ });
46
+
47
+ // Rate-limit / invalid-request observability. discord.js handles retries
48
+ // automatically; we just want logs so we know when we're flirting with
49
+ // the Cloudflare 10k-invalid-requests/10min cap.
50
+ client.rest.on("rateLimited", (info) => {
51
+ logWarn(
52
+ "discord",
53
+ `Rate-limited ${info.method} ${info.route} — wait ${info.timeToReset}ms (global=${info.global})`,
54
+ );
55
+ });
56
+ client.rest.on("invalidRequestWarning", (info) => {
57
+ logError(
58
+ "discord",
59
+ `Invalid-request warning: ${info.count} bad requests in last ${info.remainingTime}ms — approaching IP ban threshold`,
60
+ );
61
+ });
62
+ }