talon-agent 3.33.3 → 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 (75) hide show
  1. package/package.json +6 -5
  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/plugins/github/provision.ts +1 -1
  69. package/src/storage/metrics.ts +18 -0
  70. package/src/storage/session-record.ts +29 -0
  71. package/src/storage/sessions.ts +24 -0
  72. package/src/util/boot-timer.ts +31 -0
  73. package/src/util/concurrency.ts +28 -0
  74. package/src/frontend/discord/callbacks/components.ts +0 -793
  75. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Guild whitelist enforcement — the bot only operates in `allowedGuilds`.
3
+ *
4
+ * Two entry points: the sweep run once at ready (defense in depth against
5
+ * guilds joined while the bot was offline) and the `guildCreate` handler
6
+ * that reacts the moment the bot is added somewhere. Both tell the admins
7
+ * and, when `leaveUnauthorizedGuilds` is set, leave.
8
+ */
9
+
10
+ import type { Client, Guild } from "discord.js";
11
+ import { log, logError, logWarn } from "../../util/log.js";
12
+ import { registerCommandsForGuilds } from "./commands/index.js";
13
+ import type { DiscordRuntime } from "./runtime.js";
14
+
15
+ async function notifyAdmins(
16
+ client: Client,
17
+ adminUserIds: readonly string[],
18
+ content: string,
19
+ ): Promise<void> {
20
+ for (const adminId of adminUserIds) {
21
+ try {
22
+ const admin = await client.users.fetch(adminId);
23
+ await admin.send({ content, allowedMentions: { parse: [] } });
24
+ } catch {
25
+ /* ignore */
26
+ }
27
+ }
28
+ }
29
+
30
+ async function leaveGuild(guild: Guild): Promise<void> {
31
+ try {
32
+ await guild.leave();
33
+ } catch (err) {
34
+ logWarn(
35
+ "discord",
36
+ `Failed to leave guild ${guild.id}: ${err instanceof Error ? err.message : err}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /** Ready-time sweep: leave every guild the bot is in that isn't whitelisted. */
42
+ export async function leaveNonWhitelistedGuilds(
43
+ runtime: DiscordRuntime,
44
+ client: Client<true>,
45
+ ): Promise<void> {
46
+ const dc = runtime.discord;
47
+ for (const guild of client.guilds.cache.values()) {
48
+ if (dc.allowedGuilds.includes(guild.id)) continue;
49
+ log(
50
+ "discord",
51
+ `Leaving non-whitelisted guild "${guild.name}" (${guild.id})`,
52
+ );
53
+ await notifyAdmins(
54
+ client,
55
+ dc.adminUserIds,
56
+ `🚪 Leaving non-whitelisted guild "${guild.name}" (${guild.id}).`,
57
+ );
58
+ await leaveGuild(guild);
59
+ }
60
+ }
61
+
62
+ /** guildCreate: bot was added to a new guild → enforce whitelist immediately. */
63
+ export async function onGuildCreate(
64
+ runtime: DiscordRuntime,
65
+ guild: Guild,
66
+ ): Promise<void> {
67
+ const { client, config, discord: dc } = runtime;
68
+ if (dc.allowedGuilds.includes(guild.id)) {
69
+ log("discord", `Joined whitelisted guild "${guild.name}" (${guild.id})`);
70
+ // Re-register commands for this guild
71
+ try {
72
+ await registerCommandsForGuilds(client, config);
73
+ } catch (err) {
74
+ logError("discord", `Re-register commands after guildCreate failed`, err);
75
+ }
76
+ return;
77
+ }
78
+ log("discord", `Joined non-whitelisted guild "${guild.name}" (${guild.id})`);
79
+ await notifyAdmins(
80
+ client,
81
+ dc.adminUserIds,
82
+ dc.leaveUnauthorizedGuilds
83
+ ? `🚨 Bot was added to non-whitelisted guild "${guild.name}" (${guild.id}). Leaving.`
84
+ : `⚠️ Bot was added to non-whitelisted guild "${guild.name}" (${guild.id}). Commands will not be registered.`,
85
+ );
86
+ if (dc.leaveUnauthorizedGuilds) {
87
+ await leaveGuild(guild);
88
+ }
89
+ }
@@ -5,39 +5,33 @@
5
5
  * - access control (config.discord.allowedUsers / allowedGuilds / allowedChannels / adminUserIds)
6
6
  * - slash command registration (per-guild for allowedGuilds, optional global for DM)
7
7
  * - message handlers (mention/reply/channel-mode gating)
8
- * - component (button/select) interaction routing → callbacks.ts
8
+ * - component (button/select) interaction routing → callbacks/
9
9
  * - guildCreate auto-leave for non-whitelisted guilds (+ admin notify)
10
10
  * - graceful shutdown
11
11
  *
12
12
  * Registers a Discord-specific action handler with the gateway so MCP tool
13
13
  * calls (send_message, react, etc.) reach the Discord API.
14
+ *
15
+ * This file is wiring only: it constructs the shared runtime (runtime.ts),
16
+ * binds the client's event handlers, and owns the frontend surface. The
17
+ * behaviour lives in the modules — ready, guild-policy, diagnostics,
18
+ * outbound, connection, and the handlers/commands/callbacks trees.
14
19
  */
15
20
 
16
- import {
17
- Client,
18
- GatewayIntentBits,
19
- Partials,
20
- ActivityType,
21
- Events,
22
- } from "discord.js";
23
- import { once } from "node:events";
24
21
  import type { TalonConfig } from "../../util/config.js";
25
22
  import type { ContextManager } from "../../core/types.js";
26
23
  import type { Gateway } from "../../core/engine/gateway.js";
27
- import { log, logError, logWarn } from "../../util/log.js";
24
+ import { log } from "../../util/log.js";
28
25
  import { createDiscordActionHandler } from "./actions/index.js";
29
- import {
30
- registerCommandsForGuilds,
31
- registerInteractionRouter,
32
- } from "./commands/index.js";
26
+ import { registerInteractionRouter } from "./commands/index.js";
27
+ import { connect, disconnect } from "./connection.js";
28
+ import { bindClientDiagnostics } from "./diagnostics.js";
29
+ import { onGuildCreate } from "./guild-policy.js";
30
+ import { setAccessControl } from "./handlers/index.js";
33
31
  import { registerMiddleware } from "./middleware.js";
34
- import {
35
- setAccessControl,
36
- lookupDiscordChat,
37
- registerDiscordChat,
38
- } from "./handlers/index.js";
39
- import { sendChunked } from "./handlers/index.js";
40
- import { deriveNumericChatId } from "../../util/chat-id.js";
32
+ import { sendMessage, sendTyping } from "./outbound.js";
33
+ import { onClientReady } from "./ready.js";
34
+ import { createDiscordRuntime, type DiscordRuntime } from "./runtime.js";
41
35
 
42
36
  // ── Frontend type ───────────────────────────────────────────────────────────
43
37
 
@@ -54,6 +48,28 @@ export type DiscordFrontend = {
54
48
 
55
49
  // ── Factory ─────────────────────────────────────────────────────────────────
56
50
 
51
+ async function init(runtime: DiscordRuntime): Promise<void> {
52
+ const { client, config, gateway } = runtime;
53
+
54
+ // Register Discord action handler with the gateway
55
+ gateway.registerFrontendHandler(
56
+ "discord",
57
+ createDiscordActionHandler(client, gateway),
58
+ );
59
+
60
+ const port = await gateway.start(19876);
61
+ log("discord", `Gateway started on port ${port}`);
62
+
63
+ // Hook event listeners BEFORE login so we don't miss the ready event.
64
+ client.once("clientReady", (c) => onClientReady(runtime, c));
65
+ client.on("guildCreate", (guild) => onGuildCreate(runtime, guild));
66
+ bindClientDiagnostics(client);
67
+
68
+ // Wire message handlers and slash command/component routers
69
+ registerMiddleware(client, config);
70
+ registerInteractionRouter(client, config, gateway);
71
+ }
72
+
57
73
  export function createDiscordFrontend(
58
74
  config: TalonConfig,
59
75
  gateway: Gateway,
@@ -64,30 +80,9 @@ export function createDiscordFrontend(
64
80
  );
65
81
  }
66
82
  const dc = config.discord;
83
+ const runtime = createDiscordRuntime(config, dc, gateway);
67
84
 
68
- const client = new Client({
69
- intents: [
70
- GatewayIntentBits.Guilds,
71
- GatewayIntentBits.GuildMessages,
72
- GatewayIntentBits.GuildMembers,
73
- GatewayIntentBits.MessageContent,
74
- GatewayIntentBits.DirectMessages,
75
- GatewayIntentBits.GuildMessageReactions,
76
- GatewayIntentBits.DirectMessageReactions,
77
- ],
78
- // Partials.Reaction is what makes reactions on messages that predate the
79
- // current cache arrive at all — without it the soul's reaction tap only
80
- // ever sees freshly-cached messages.
81
- partials: [
82
- Partials.Channel,
83
- Partials.Message,
84
- Partials.User,
85
- Partials.Reaction,
86
- ],
87
- allowedMentions: { parse: [] }, // never ping anyone unless we explicitly opt in
88
- });
89
-
90
- // Wire access control config into handlers.ts
85
+ // Wire access control config into handlers/
91
86
  setAccessControl({
92
87
  allowedUsers: dc.allowedUsers,
93
88
  allowedGuilds: dc.allowedGuilds,
@@ -103,270 +98,14 @@ export function createDiscordFrontend(
103
98
  getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
104
99
  };
105
100
 
106
- // sendTyping: looks up the registered Discord channel and sends typing.
107
- // No-ops gracefully if we don't have the channel cached yet.
108
- const sendTyping = async (chatId: number): Promise<void> => {
109
- const info = lookupDiscordChat(chatId);
110
- if (!info) return;
111
- try {
112
- const ch = await client.channels.fetch(info.channelId);
113
- if (ch && "sendTyping" in ch) {
114
- await (ch as { sendTyping: () => Promise<void> }).sendTyping();
115
- }
116
- } catch {
117
- /* channel not resolvable — ignore */
118
- }
119
- };
120
-
121
- // sendMessage (plain): used by cron and pulse to reach a specific chat.
122
- // chatId here is the numericChatId stored in the registry.
123
- const sendMessage = async (chatId: number, text: string): Promise<void> => {
124
- if (!text.trim()) return;
125
- const info = lookupDiscordChat(chatId);
126
- if (!info) {
127
- logWarn(
128
- "discord",
129
- `sendMessage: no registered channel for chatId ${chatId}`,
130
- );
131
- return;
132
- }
133
- try {
134
- const ch = await client.channels.fetch(info.channelId);
135
- if (
136
- ch &&
137
- "isSendable" in ch &&
138
- (ch as { isSendable: () => boolean }).isSendable()
139
- ) {
140
- await sendChunked(ch as never, text);
141
- }
142
- } catch (err) {
143
- logError(
144
- "discord",
145
- `sendMessage failed for chat ${chatId}: ${err instanceof Error ? err.message : err}`,
146
- );
147
- }
148
- };
149
-
150
101
  return {
151
102
  name: "discord",
152
103
  context,
153
- sendTyping,
154
- sendMessage,
104
+ sendTyping: (chatId) => sendTyping(runtime, chatId),
105
+ sendMessage: (chatId, text) => sendMessage(runtime, chatId, text),
155
106
  getBridgePort: () => gateway.getPort(),
156
-
157
- async init() {
158
- // Register Discord action handler with the gateway
159
- gateway.registerFrontendHandler(
160
- "discord",
161
- createDiscordActionHandler(client, gateway),
162
- );
163
-
164
- const port = await gateway.start(19876);
165
- log("discord", `Gateway started on port ${port}`);
166
-
167
- // Hook event listeners BEFORE login so we don't miss the ready event.
168
- client.once("clientReady", async (c) => {
169
- log("discord", `Logged in as ${c.user.tag} (${c.user.id})`);
170
-
171
- if (dc.presence) {
172
- try {
173
- c.user.setPresence({
174
- activities: [{ name: dc.presence, type: ActivityType.Custom }],
175
- status: "online",
176
- });
177
- } catch {
178
- /* ignore */
179
- }
180
- }
181
-
182
- // Discover any existing DM channels with allowedUsers and register them
183
- // in the chat registry so cron/pulse can target them. We don't pre-open
184
- // unrequested DMs — only those Discord already exposes.
185
- for (const userId of dc.allowedUsers) {
186
- try {
187
- const user = await c.users.fetch(userId);
188
- const dm = await user.createDM();
189
- const chatId = `discord_dm_${userId}`;
190
- registerDiscordChat({
191
- channelId: dm.id,
192
- guildId: null,
193
- userId,
194
- numericChatId: deriveNumericChatId(chatId),
195
- chatId,
196
- });
197
- } catch {
198
- /* user not reachable — skip */
199
- }
200
- }
201
-
202
- // Register slash commands per-guild + optionally global for DM
203
- try {
204
- await registerCommandsForGuilds(c, config);
205
- } catch (err) {
206
- logError("discord", "Slash command registration failed", err);
207
- }
208
-
209
- // Defense in depth: leave any guild we're in but isn't whitelisted
210
- if (dc.leaveUnauthorizedGuilds) {
211
- for (const guild of c.guilds.cache.values()) {
212
- if (dc.allowedGuilds.includes(guild.id)) continue;
213
- log(
214
- "discord",
215
- `Leaving non-whitelisted guild "${guild.name}" (${guild.id})`,
216
- );
217
- // Notify admins
218
- for (const adminId of dc.adminUserIds) {
219
- try {
220
- const admin = await c.users.fetch(adminId);
221
- await admin.send({
222
- content: `🚪 Leaving non-whitelisted guild "${guild.name}" (${guild.id}).`,
223
- allowedMentions: { parse: [] },
224
- });
225
- } catch {
226
- /* ignore */
227
- }
228
- }
229
- try {
230
- await guild.leave();
231
- } catch (err) {
232
- logWarn(
233
- "discord",
234
- `Failed to leave guild ${guild.id}: ${err instanceof Error ? err.message : err}`,
235
- );
236
- }
237
- }
238
- }
239
- });
240
-
241
- // guildCreate: bot was added to a new guild → enforce whitelist immediately
242
- client.on("guildCreate", async (guild) => {
243
- if (dc.allowedGuilds.includes(guild.id)) {
244
- log(
245
- "discord",
246
- `Joined whitelisted guild "${guild.name}" (${guild.id})`,
247
- );
248
- // Re-register commands for this guild
249
- try {
250
- await registerCommandsForGuilds(client, config);
251
- } catch (err) {
252
- logError(
253
- "discord",
254
- `Re-register commands after guildCreate failed`,
255
- err,
256
- );
257
- }
258
- return;
259
- }
260
- log(
261
- "discord",
262
- `Joined non-whitelisted guild "${guild.name}" (${guild.id})`,
263
- );
264
- // Notify admins
265
- for (const adminId of dc.adminUserIds) {
266
- try {
267
- const admin = await client.users.fetch(adminId);
268
- await admin.send({
269
- content: dc.leaveUnauthorizedGuilds
270
- ? `🚨 Bot was added to non-whitelisted guild "${guild.name}" (${guild.id}). Leaving.`
271
- : `⚠️ Bot was added to non-whitelisted guild "${guild.name}" (${guild.id}). Commands will not be registered.`,
272
- allowedMentions: { parse: [] },
273
- });
274
- } catch {
275
- /* ignore */
276
- }
277
- }
278
- if (dc.leaveUnauthorizedGuilds) {
279
- try {
280
- await guild.leave();
281
- } catch (err) {
282
- logWarn(
283
- "discord",
284
- `Failed to leave guild ${guild.id}: ${err instanceof Error ? err.message : err}`,
285
- );
286
- }
287
- }
288
- });
289
-
290
- client.on("error", (err) => {
291
- logError("discord", "Client error", err);
292
- });
293
- client.on("warn", (msg) => {
294
- logWarn("discord", msg);
295
- });
296
-
297
- // Surface gateway close codes — discord.js auto-reconnects on most, but
298
- // 4004 (auth failed), 4013 (invalid intents), 4014 (disallowed intents)
299
- // are terminal: the bot will hang silent in IDENTIFY without an alert.
300
- client.on(Events.ShardDisconnect, (event, shardId) => {
301
- const code = event?.code;
302
- const reason = event?.reason || "(no reason)";
303
- const fatal = code === 4004 || code === 4013 || code === 4014;
304
- const label =
305
- code === 4004
306
- ? "AUTHENTICATION_FAILED — bot token invalid/revoked"
307
- : code === 4013
308
- ? "INVALID_INTENTS — declared intent doesn't exist"
309
- : code === 4014
310
- ? "DISALLOWED_INTENTS — privileged intent (GuildMembers/MessageContent/Presence) not enabled in Developer Portal"
311
- : `code=${code}`;
312
- if (fatal) {
313
- logError(
314
- "discord",
315
- `Shard ${shardId} disconnected fatally: ${label} (${reason})`,
316
- );
317
- } else {
318
- logWarn(
319
- "discord",
320
- `Shard ${shardId} disconnected: ${label} (${reason})`,
321
- );
322
- }
323
- });
324
-
325
- // Rate-limit / invalid-request observability. discord.js handles retries
326
- // automatically; we just want logs so we know when we're flirting with
327
- // the Cloudflare 10k-invalid-requests/10min cap.
328
- client.rest.on("rateLimited", (info) => {
329
- logWarn(
330
- "discord",
331
- `Rate-limited ${info.method} ${info.route} — wait ${info.timeToReset}ms (global=${info.global})`,
332
- );
333
- });
334
- client.rest.on("invalidRequestWarning", (info) => {
335
- logError(
336
- "discord",
337
- `Invalid-request warning: ${info.count} bad requests in last ${info.remainingTime}ms — approaching IP ban threshold`,
338
- );
339
- });
340
-
341
- // Wire message handlers and slash command/component routers
342
- registerMiddleware(client, config);
343
- registerInteractionRouter(client, config, gateway);
344
- },
345
-
346
- async start() {
347
- await client.login(dc.botToken);
348
- // Wait for the WebSocket READY event before returning — login() resolves
349
- // after HTTP IDENTIFY but client.user / client.guilds.cache are empty
350
- // until READY fires. Anything called after start() can then safely read
351
- // gateway state.
352
- if (!client.isReady()) {
353
- await once(client, Events.ClientReady);
354
- }
355
- },
356
-
357
- async stop() {
358
- try {
359
- await client.destroy();
360
- log("shutdown", "Discord client disconnected");
361
- } catch (err) {
362
- logError("shutdown", "Discord stop error", err);
363
- }
364
- try {
365
- await gateway.stop();
366
- log("shutdown", "Gateway stopped");
367
- } catch (err) {
368
- logError("shutdown", "Gateway stop error", err);
369
- }
370
- },
107
+ init: () => init(runtime),
108
+ start: () => connect(runtime),
109
+ stop: () => disconnect(runtime),
371
110
  };
372
111
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Outbound primitives the rest of the daemon reaches Discord through:
3
+ * typing indicators and plain sends addressed by numeric chat id (cron,
4
+ * pulse). Both resolve the channel via the chat registry and no-op when it
5
+ * isn't known yet.
6
+ */
7
+
8
+ import { logError, logWarn } from "../../util/log.js";
9
+ import { lookupDiscordChat, sendChunked } from "./handlers/index.js";
10
+ import type { DiscordRuntime } from "./runtime.js";
11
+
12
+ // sendTyping: looks up the registered Discord channel and sends typing.
13
+ // No-ops gracefully if we don't have the channel cached yet.
14
+ export async function sendTyping(
15
+ runtime: DiscordRuntime,
16
+ chatId: number,
17
+ ): Promise<void> {
18
+ const info = lookupDiscordChat(chatId);
19
+ if (!info) return;
20
+ try {
21
+ const ch = await runtime.client.channels.fetch(info.channelId);
22
+ if (ch && "sendTyping" in ch) {
23
+ await (ch as { sendTyping: () => Promise<void> }).sendTyping();
24
+ }
25
+ } catch {
26
+ /* channel not resolvable — ignore */
27
+ }
28
+ }
29
+
30
+ // sendMessage (plain): used by cron and pulse to reach a specific chat.
31
+ // chatId here is the numericChatId stored in the registry.
32
+ export async function sendMessage(
33
+ runtime: DiscordRuntime,
34
+ chatId: number,
35
+ text: string,
36
+ ): Promise<void> {
37
+ if (!text.trim()) return;
38
+ const info = lookupDiscordChat(chatId);
39
+ if (!info) {
40
+ logWarn(
41
+ "discord",
42
+ `sendMessage: no registered channel for chatId ${chatId}`,
43
+ );
44
+ return;
45
+ }
46
+ try {
47
+ const ch = await runtime.client.channels.fetch(info.channelId);
48
+ if (
49
+ ch &&
50
+ "isSendable" in ch &&
51
+ (ch as { isSendable: () => boolean }).isSendable()
52
+ ) {
53
+ await sendChunked(ch as never, text);
54
+ }
55
+ } catch (err) {
56
+ logError(
57
+ "discord",
58
+ `sendMessage failed for chat ${chatId}: ${err instanceof Error ? err.message : err}`,
59
+ );
60
+ }
61
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The `clientReady` handler — everything that needs a logged-in client:
3
+ * presence, DM discovery for the chat registry, slash-command registration,
4
+ * and the guild whitelist sweep.
5
+ */
6
+
7
+ import { ActivityType, type Client } from "discord.js";
8
+ import { log, logError } from "../../util/log.js";
9
+ import { deriveNumericChatId } from "../../util/chat-id.js";
10
+ import { registerCommandsForGuilds } from "./commands/index.js";
11
+ import { registerDiscordChat } from "./handlers/index.js";
12
+ import { leaveNonWhitelistedGuilds } from "./guild-policy.js";
13
+ import type { DiscordRuntime } from "./runtime.js";
14
+
15
+ /**
16
+ * Discover any existing DM channels with allowedUsers and register them in
17
+ * the chat registry so cron/pulse can target them. We don't pre-open
18
+ * unrequested DMs — only those Discord already exposes.
19
+ */
20
+ async function registerAllowedUserDms(
21
+ runtime: DiscordRuntime,
22
+ client: Client<true>,
23
+ ): Promise<void> {
24
+ for (const userId of runtime.discord.allowedUsers) {
25
+ try {
26
+ const user = await client.users.fetch(userId);
27
+ const dm = await user.createDM();
28
+ const chatId = `discord_dm_${userId}`;
29
+ registerDiscordChat({
30
+ channelId: dm.id,
31
+ guildId: null,
32
+ userId,
33
+ numericChatId: deriveNumericChatId(chatId),
34
+ chatId,
35
+ });
36
+ } catch {
37
+ /* user not reachable — skip */
38
+ }
39
+ }
40
+ }
41
+
42
+ export async function onClientReady(
43
+ runtime: DiscordRuntime,
44
+ client: Client<true>,
45
+ ): Promise<void> {
46
+ const { config, discord: dc } = runtime;
47
+ log("discord", `Logged in as ${client.user.tag} (${client.user.id})`);
48
+
49
+ if (dc.presence) {
50
+ try {
51
+ client.user.setPresence({
52
+ activities: [{ name: dc.presence, type: ActivityType.Custom }],
53
+ status: "online",
54
+ });
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+
60
+ await registerAllowedUserDms(runtime, client);
61
+
62
+ // Register slash commands per-guild + optionally global for DM
63
+ try {
64
+ await registerCommandsForGuilds(client, config);
65
+ } catch (err) {
66
+ logError("discord", "Slash command registration failed", err);
67
+ }
68
+
69
+ // Defense in depth: leave any guild we're in but isn't whitelisted
70
+ if (dc.leaveUnauthorizedGuilds) {
71
+ await leaveNonWhitelistedGuilds(runtime, client);
72
+ }
73
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Discord frontend runtime — the state every module of this frontend shares.
3
+ *
4
+ * `createDiscordFrontend` used to hold the client and config as closure
5
+ * variables with every gateway-event handler nested inside `init`. It is
6
+ * now one explicit object, constructed once, that each module (ready,
7
+ * guild-policy, diagnostics, outbound, connection) takes as its first
8
+ * parameter. The runtime carries state only; the modules own the behaviour.
9
+ */
10
+
11
+ import { Client, GatewayIntentBits, Partials } from "discord.js";
12
+ import type { TalonConfig } from "../../util/config.js";
13
+ import type { Gateway } from "../../core/engine/gateway.js";
14
+
15
+ /** `config.discord`, narrowed once — the factory refuses to build without it. */
16
+ export type DiscordConfig = NonNullable<TalonConfig["discord"]>;
17
+
18
+ export type DiscordRuntime = {
19
+ readonly config: TalonConfig;
20
+ readonly discord: DiscordConfig;
21
+ readonly gateway: Gateway;
22
+ readonly client: Client;
23
+ };
24
+
25
+ function createClient(): Client {
26
+ return new Client({
27
+ intents: [
28
+ GatewayIntentBits.Guilds,
29
+ GatewayIntentBits.GuildMessages,
30
+ GatewayIntentBits.GuildMembers,
31
+ GatewayIntentBits.MessageContent,
32
+ GatewayIntentBits.DirectMessages,
33
+ GatewayIntentBits.GuildMessageReactions,
34
+ GatewayIntentBits.DirectMessageReactions,
35
+ ],
36
+ // Partials.Reaction is what makes reactions on messages that predate the
37
+ // current cache arrive at all — without it the soul's reaction tap only
38
+ // ever sees freshly-cached messages.
39
+ partials: [
40
+ Partials.Channel,
41
+ Partials.Message,
42
+ Partials.User,
43
+ Partials.Reaction,
44
+ ],
45
+ allowedMentions: { parse: [] }, // never ping anyone unless we explicitly opt in
46
+ });
47
+ }
48
+
49
+ export function createDiscordRuntime(
50
+ config: TalonConfig,
51
+ discord: DiscordConfig,
52
+ gateway: Gateway,
53
+ ): DiscordRuntime {
54
+ return { config, discord, gateway, client: createClient() };
55
+ }