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.
- package/package.json +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- 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
|
|
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
|
|
24
|
+
import { log } from "../../util/log.js";
|
|
28
25
|
import { createDiscordActionHandler } from "./actions/index.js";
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from "./
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
+
}
|