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,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
+ }
@@ -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
+ }