@songsid/agend 2.1.4-beta.39 → 2.1.4-beta.40
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/dist/channel/adapters/discord.d.ts +42 -2
- package/dist/channel/adapters/discord.js +405 -52
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/types.d.ts +28 -0
- package/dist/daemon.d.ts +6 -4
- package/dist/daemon.js +65 -29
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +14 -1
- package/dist/doctor.js +41 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-manager.d.ts +14 -1
- package/dist/fleet-manager.js +164 -92
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +6 -0
- package/dist/instance-lifecycle.js +25 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +29 -7
- package/dist/locale.js.map +1 -1
- package/dist/spawn-gate.d.ts +30 -0
- package/dist/spawn-gate.js +79 -0
- package/dist/spawn-gate.js.map +1 -0
- package/dist/storm-window.d.ts +83 -0
- package/dist/storm-window.js +251 -0
- package/dist/storm-window.js.map +1 -0
- package/dist/tmux-manager.d.ts +3 -0
- package/dist/tmux-manager.js +36 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/ui/view.html +5 -5
- package/package.json +1 -1
|
@@ -6,9 +6,13 @@ import { join } from "node:path";
|
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
8
|
import { createWriteStream } from "node:fs";
|
|
9
|
-
import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, MessageFlags, } from "discord.js";
|
|
9
|
+
import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, MessageFlags, Status, } from "discord.js";
|
|
10
10
|
import { MessageQueue } from "../message-queue.js";
|
|
11
11
|
const DISCORD_MAX_LENGTH = 2000;
|
|
12
|
+
const GATEWAY_WATCHDOG_INTERVAL_MS = 30_000;
|
|
13
|
+
const GATEWAY_STALE_MIN_MS = 180_000;
|
|
14
|
+
const GATEWAY_RECONNECT_WAIT_MS = 30_000;
|
|
15
|
+
const GATEWAY_MAX_BACKOFF_MS = 5 * 60_000;
|
|
12
16
|
/** Curated ClassicBot backends for Discord's native slash-option dropdown. */
|
|
13
17
|
export const DISCORD_START_BACKEND_CHOICES = [
|
|
14
18
|
{ name: "Claude Code", value: "claude-code" },
|
|
@@ -24,6 +28,27 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
24
28
|
topology = "channels";
|
|
25
29
|
id;
|
|
26
30
|
client;
|
|
31
|
+
clientFactory;
|
|
32
|
+
now;
|
|
33
|
+
watchdogIntervalMs;
|
|
34
|
+
staleThresholdMs;
|
|
35
|
+
reconnectBaseDelayMs;
|
|
36
|
+
desiredRunning = false;
|
|
37
|
+
lifecycleEpoch = 0;
|
|
38
|
+
clientGeneration = 0;
|
|
39
|
+
reconnectPromise = null;
|
|
40
|
+
watchdogTimer = null;
|
|
41
|
+
lastWatchdogTickAt = 0;
|
|
42
|
+
generationReadyAt = 0;
|
|
43
|
+
generationNonReadySince = 0;
|
|
44
|
+
shardNonReadySince = new Map();
|
|
45
|
+
previousHeartbeatAck = new Map();
|
|
46
|
+
observedHeartbeatInterval = new Map();
|
|
47
|
+
healthStatus = "stopped";
|
|
48
|
+
lastDispatchAt = null;
|
|
49
|
+
lastReconnectAt = null;
|
|
50
|
+
lastReconnectReason = null;
|
|
51
|
+
reconnectCount = 0;
|
|
27
52
|
botToken;
|
|
28
53
|
accessManager;
|
|
29
54
|
inboxDir;
|
|
@@ -47,20 +72,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
47
72
|
this.generalChannelId = opts.generalChannelId;
|
|
48
73
|
this.registerCommands = opts.registerCommands !== false;
|
|
49
74
|
mkdirSync(this.inboxDir, { recursive: true });
|
|
50
|
-
this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
GatewayIntentBits.GuildMessageReactions,
|
|
57
|
-
],
|
|
58
|
-
// Reactions on messages that are not in the cache — which is every message
|
|
59
|
-
// sent before the current process started, i.e. the common case after any
|
|
60
|
-
// restart — arrive as PARTIAL objects. Without these, discord.js drops those
|
|
61
|
-
// events entirely and reactions appear to work only on very recent messages.
|
|
62
|
-
partials: [Partials.Message, Partials.Reaction, Partials.User],
|
|
63
|
-
});
|
|
75
|
+
this.clientFactory = opts.clientFactory ?? (() => this.buildClient());
|
|
76
|
+
this.now = opts.now ?? Date.now;
|
|
77
|
+
this.watchdogIntervalMs = opts.watchdogIntervalMs ?? GATEWAY_WATCHDOG_INTERVAL_MS;
|
|
78
|
+
this.staleThresholdMs = opts.staleThresholdMs ?? GATEWAY_STALE_MIN_MS;
|
|
79
|
+
this.reconnectBaseDelayMs = opts.reconnectBaseDelayMs ?? 5_000;
|
|
80
|
+
this.client = this.clientFactory();
|
|
64
81
|
this.queue = new MessageQueue({
|
|
65
82
|
send: async (chatId, threadId, text) => {
|
|
66
83
|
const channel = await this._fetchTextChannel(threadId ?? chatId);
|
|
@@ -78,23 +95,87 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
78
95
|
return { messageId: msg.id };
|
|
79
96
|
},
|
|
80
97
|
});
|
|
81
|
-
this.
|
|
98
|
+
this.registerClientHandlers(this.client, this.clientGeneration);
|
|
99
|
+
}
|
|
100
|
+
buildClient() {
|
|
101
|
+
return new Client({
|
|
102
|
+
intents: [
|
|
103
|
+
GatewayIntentBits.Guilds,
|
|
104
|
+
GatewayIntentBits.GuildMessages,
|
|
105
|
+
GatewayIntentBits.MessageContent,
|
|
106
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
107
|
+
],
|
|
108
|
+
// Events for messages/reactions created before this process started are partial.
|
|
109
|
+
partials: [Partials.Message, Partials.Reaction, Partials.User],
|
|
110
|
+
});
|
|
82
111
|
}
|
|
83
112
|
async _fetchTextChannel(channelId) {
|
|
84
|
-
const
|
|
113
|
+
const client = await this.readyClient();
|
|
114
|
+
const channel = await client.channels.fetch(channelId);
|
|
85
115
|
if (!channel?.isTextBased()) {
|
|
86
116
|
throw new Error(`Channel ${channelId} is not a text channel`);
|
|
87
117
|
}
|
|
88
118
|
return channel;
|
|
89
119
|
}
|
|
90
|
-
|
|
120
|
+
registerClientHandlers(client, generation) {
|
|
91
121
|
// Client/shard errors (WebSocket hiccups, gateway resumes, etc.). discord.js
|
|
92
122
|
// auto-reconnects; we just need a listener so Node's EventEmitter doesn't
|
|
93
123
|
// rethrow on "error" — without one it surfaces as an uncaughtException and
|
|
94
124
|
// the process-level handler tears down the whole fleet (the built-in adapter
|
|
95
125
|
// shares the fleet process).
|
|
96
|
-
|
|
97
|
-
|
|
126
|
+
client.on("error", (err) => {
|
|
127
|
+
if (!this.isCurrentClient(client, generation))
|
|
128
|
+
return;
|
|
129
|
+
console.warn(`[discord:${this.id}] client error: ${err?.message ?? err}`);
|
|
130
|
+
this.healthStatus = "retrying";
|
|
131
|
+
this.emitHealthChanged();
|
|
132
|
+
});
|
|
133
|
+
client.on("shardError", (err, shardId) => {
|
|
134
|
+
if (!this.isCurrentClient(client, generation))
|
|
135
|
+
return;
|
|
136
|
+
console.warn(`[discord:${this.id}] shard ${shardId} error: ${err?.message ?? err}`);
|
|
137
|
+
this.healthStatus = "retrying";
|
|
138
|
+
this.emitHealthChanged();
|
|
139
|
+
});
|
|
140
|
+
client.on("shardReconnecting", (shardId) => {
|
|
141
|
+
if (!this.isCurrentClient(client, generation))
|
|
142
|
+
return;
|
|
143
|
+
this.healthStatus = "retrying";
|
|
144
|
+
console.warn(`[discord:${this.id}] shard ${shardId} reconnecting`);
|
|
145
|
+
});
|
|
146
|
+
client.on("shardResume", (shardId, replayedEvents) => {
|
|
147
|
+
if (!this.isCurrentClient(client, generation))
|
|
148
|
+
return;
|
|
149
|
+
this.healthStatus = "connected";
|
|
150
|
+
this.shardNonReadySince.delete(shardId);
|
|
151
|
+
console.info(`[discord:${this.id}] shard ${shardId} resumed (${replayedEvents} replayed event(s))`);
|
|
152
|
+
});
|
|
153
|
+
client.on("shardReady", (shardId) => {
|
|
154
|
+
if (!this.isCurrentClient(client, generation))
|
|
155
|
+
return;
|
|
156
|
+
this.healthStatus = "connected";
|
|
157
|
+
this.shardNonReadySince.delete(shardId);
|
|
158
|
+
});
|
|
159
|
+
client.on("shardDisconnect", (event, shardId) => {
|
|
160
|
+
if (!this.isCurrentClient(client, generation))
|
|
161
|
+
return;
|
|
162
|
+
this.healthStatus = "retrying";
|
|
163
|
+
this.shardNonReadySince.set(shardId, this.now());
|
|
164
|
+
console.warn(`[discord:${this.id}] shard ${shardId} disconnected (${event.code}: ${event.reason || "no reason"})`);
|
|
165
|
+
});
|
|
166
|
+
client.on("invalidated", () => {
|
|
167
|
+
if (!this.isCurrentClient(client, generation))
|
|
168
|
+
return;
|
|
169
|
+
console.warn(`[discord:${this.id}] gateway session invalidated; rebuilding client`);
|
|
170
|
+
void this.reconnectGateway("gateway session invalidated").catch(() => { });
|
|
171
|
+
});
|
|
172
|
+
client.on("raw", (packet) => {
|
|
173
|
+
if (!this.isCurrentClient(client, generation))
|
|
174
|
+
return;
|
|
175
|
+
if (packet.t)
|
|
176
|
+
this.lastDispatchAt = this.now();
|
|
177
|
+
});
|
|
178
|
+
client.once("ready", () => void this.handleClientReady(client, generation));
|
|
98
179
|
// Reactions on the bot's messages, as inbound events (#408). Both add and remove
|
|
99
180
|
// are reported so an agent can see an approval being withdrawn.
|
|
100
181
|
const onReaction = async (reaction, user, action) => {
|
|
@@ -109,7 +190,9 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
109
190
|
return;
|
|
110
191
|
}
|
|
111
192
|
}
|
|
112
|
-
if (
|
|
193
|
+
if (!this.isCurrentClient(client, generation))
|
|
194
|
+
return;
|
|
195
|
+
if (user.id === client.user?.id)
|
|
113
196
|
return; // our own reaction
|
|
114
197
|
// Other bots' reactions are DELIVERED on purpose: agents react to each
|
|
115
198
|
// other's messages as signals (agent A 👍 → agent B sees it). The noise
|
|
@@ -119,11 +202,11 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
119
202
|
const message = reaction.message;
|
|
120
203
|
// Only reactions on OUR messages are meaningful as agent signals; a user
|
|
121
204
|
// reacting to another user's message is chatter.
|
|
122
|
-
if (message.author?.id && message.author.id !==
|
|
205
|
+
if (message.author?.id && message.author.id !== client.user?.id)
|
|
123
206
|
return;
|
|
124
207
|
if (message.guildId && message.guildId !== this.guildId && !this.openChannels.has(message.channelId))
|
|
125
208
|
return;
|
|
126
|
-
this.
|
|
209
|
+
this.emitFromClient(client, generation, "reaction", {
|
|
127
210
|
source: "discord",
|
|
128
211
|
adapterId: this.id,
|
|
129
212
|
chatId: this.guildId,
|
|
@@ -142,11 +225,13 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
142
225
|
console.warn(`[discord] reaction ${action} handler error (${err.message})`);
|
|
143
226
|
}
|
|
144
227
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
client.on("messageReactionAdd", (reaction, user) => void onReaction(reaction, user, "add"));
|
|
229
|
+
client.on("messageReactionRemove", (reaction, user) => void onReaction(reaction, user, "remove"));
|
|
230
|
+
client.on("messageCreate", async (msg) => {
|
|
148
231
|
try {
|
|
149
|
-
if (
|
|
232
|
+
if (!this.isCurrentClient(client, generation))
|
|
233
|
+
return;
|
|
234
|
+
if (msg.author.id === client.user?.id)
|
|
150
235
|
return; // Ignore own messages
|
|
151
236
|
if (!msg.guildId)
|
|
152
237
|
return;
|
|
@@ -320,7 +405,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
320
405
|
else
|
|
321
406
|
break;
|
|
322
407
|
}
|
|
323
|
-
this.
|
|
408
|
+
this.emitFromClient(client, generation, "message", {
|
|
324
409
|
source: "discord",
|
|
325
410
|
adapterId: this.id,
|
|
326
411
|
chatId,
|
|
@@ -345,8 +430,10 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
345
430
|
// Handle button interactions and slash commands
|
|
346
431
|
// Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
|
|
347
432
|
// interaction expires (>3s). Catch to prevent crashing the entire daemon.
|
|
348
|
-
|
|
433
|
+
client.on("interactionCreate", async (interaction) => {
|
|
349
434
|
try {
|
|
435
|
+
if (!this.isCurrentClient(client, generation))
|
|
436
|
+
return;
|
|
350
437
|
// Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
|
|
351
438
|
// A button has a 3s ack window; any early return (unknown guild/channel)
|
|
352
439
|
// or a downstream no-op (e.g. the cancel button was already cleared) would
|
|
@@ -363,7 +450,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
363
450
|
// console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
|
|
364
451
|
return;
|
|
365
452
|
}
|
|
366
|
-
this.
|
|
453
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
367
454
|
callbackData: interaction.customId,
|
|
368
455
|
chatId: this.guildId,
|
|
369
456
|
threadId: interaction.channelId,
|
|
@@ -391,7 +478,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
391
478
|
const callbackData = interaction.values[0];
|
|
392
479
|
if (!callbackData)
|
|
393
480
|
return;
|
|
394
|
-
this.
|
|
481
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
395
482
|
callbackData,
|
|
396
483
|
chatId: this.guildId,
|
|
397
484
|
threadId: interaction.channelId,
|
|
@@ -411,7 +498,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
411
498
|
if (interaction.commandName === "chat") {
|
|
412
499
|
const text = interaction.options.getString("message") ?? "";
|
|
413
500
|
await interaction.deferReply();
|
|
414
|
-
this.
|
|
501
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
415
502
|
command: "chat",
|
|
416
503
|
channelId: interaction.channelId,
|
|
417
504
|
channelName,
|
|
@@ -438,7 +525,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
438
525
|
for (const opt of interaction.options.data) {
|
|
439
526
|
options[opt.name] = opt.value;
|
|
440
527
|
}
|
|
441
|
-
this.
|
|
528
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
442
529
|
command: interaction.commandName,
|
|
443
530
|
channelId: interaction.channelId,
|
|
444
531
|
channelName,
|
|
@@ -484,14 +571,18 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
484
571
|
}
|
|
485
572
|
});
|
|
486
573
|
// Handle channel deletion (equivalent to topic_closed)
|
|
487
|
-
|
|
488
|
-
this.
|
|
574
|
+
client.on("guildCreate", (guild) => {
|
|
575
|
+
if (!this.isCurrentClient(client, generation))
|
|
576
|
+
return;
|
|
577
|
+
this.emitFromClient(client, generation, "new_group_detected", {
|
|
489
578
|
groupId: guild.id,
|
|
490
579
|
groupTitle: guild.name,
|
|
491
580
|
source: "discord",
|
|
492
581
|
});
|
|
493
582
|
});
|
|
494
|
-
|
|
583
|
+
client.on("channelDelete", (channel) => {
|
|
584
|
+
if (!this.isCurrentClient(client, generation))
|
|
585
|
+
return;
|
|
495
586
|
if (!("guildId" in channel))
|
|
496
587
|
return;
|
|
497
588
|
if (channel.guildId !== this.guildId) {
|
|
@@ -499,7 +590,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
499
590
|
return;
|
|
500
591
|
// Allowed: an open classic channel in a non-primary guild was deleted.
|
|
501
592
|
}
|
|
502
|
-
this.
|
|
593
|
+
this.emitFromClient(client, generation, "topic_closed", {
|
|
503
594
|
chatId: this.guildId,
|
|
504
595
|
threadId: channel.id,
|
|
505
596
|
});
|
|
@@ -511,14 +602,247 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
511
602
|
// console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
|
|
512
603
|
}
|
|
513
604
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
605
|
+
isCurrentClient(client, generation) {
|
|
606
|
+
// lifecycleEpoch===0 preserves constructor-level handler tests; a real Client
|
|
607
|
+
// cannot dispatch gateway events before start/login.
|
|
608
|
+
return (this.desiredRunning || this.lifecycleEpoch === 0)
|
|
609
|
+
&& this.client === client
|
|
610
|
+
&& this.clientGeneration === generation;
|
|
611
|
+
}
|
|
612
|
+
emitFromClient(client, generation, event, ...args) {
|
|
613
|
+
return this.isCurrentClient(client, generation) ? this.emit(event, ...args) : false;
|
|
614
|
+
}
|
|
615
|
+
emitHealthChanged() {
|
|
616
|
+
this.emit("gateway_health", this.getHealthSnapshot());
|
|
617
|
+
}
|
|
618
|
+
async loginFreshClient(reason, epoch) {
|
|
619
|
+
const oldClient = this.client;
|
|
620
|
+
// A destroyed discord.js Client cannot be logged in again. Destroy first to
|
|
621
|
+
// avoid overlapping gateway sessions delivering MESSAGE_CREATE twice.
|
|
622
|
+
oldClient.destroy();
|
|
623
|
+
const client = this.clientFactory();
|
|
624
|
+
const generation = ++this.clientGeneration;
|
|
625
|
+
this.client = client;
|
|
626
|
+
this.generationReadyAt = 0;
|
|
627
|
+
this.generationNonReadySince = this.now();
|
|
628
|
+
this.shardNonReadySince.clear();
|
|
629
|
+
this.previousHeartbeatAck.clear();
|
|
630
|
+
this.observedHeartbeatInterval.clear();
|
|
631
|
+
this.registerClientHandlers(client, generation);
|
|
632
|
+
this.emitHealthChanged();
|
|
633
|
+
try {
|
|
634
|
+
await client.login(this.botToken);
|
|
635
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch || this.client !== client) {
|
|
636
|
+
client.destroy();
|
|
637
|
+
throw new Error("Discord gateway login superseded by lifecycle change");
|
|
638
|
+
}
|
|
639
|
+
this.lastReconnectAt = this.now();
|
|
640
|
+
this.lastReconnectReason = reason;
|
|
641
|
+
this.healthStatus = client.isReady() ? "connected" : "starting";
|
|
642
|
+
this.emitHealthChanged();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
client.destroy();
|
|
646
|
+
throw err;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/** Rebuild just this bot's gateway Client. Calls coalesce into one generation. */
|
|
650
|
+
reconnectGateway(reason) {
|
|
651
|
+
if (this.reconnectPromise)
|
|
652
|
+
return this.reconnectPromise;
|
|
653
|
+
if (!this.desiredRunning)
|
|
654
|
+
return Promise.reject(new Error("Discord adapter is stopped"));
|
|
655
|
+
const epoch = this.lifecycleEpoch;
|
|
656
|
+
const reconnect = (async () => {
|
|
657
|
+
// Yield once so reconnectPromise is installed before destroy/login can emit
|
|
658
|
+
// a synchronous gateway error and re-enter this method.
|
|
659
|
+
await Promise.resolve();
|
|
660
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch) {
|
|
661
|
+
throw new Error("Discord gateway reconnect cancelled by adapter stop");
|
|
662
|
+
}
|
|
663
|
+
this.healthStatus = "retrying";
|
|
664
|
+
this.lastReconnectReason = reason;
|
|
665
|
+
this.reconnectCount++;
|
|
666
|
+
this.emitHealthChanged();
|
|
667
|
+
for (let attempt = 0; this.desiredRunning && epoch === this.lifecycleEpoch; attempt++) {
|
|
668
|
+
if (attempt > 0) {
|
|
669
|
+
const delay = Math.min(this.reconnectBaseDelayMs * 2 ** Math.min(attempt - 1, 6), GATEWAY_MAX_BACKOFF_MS);
|
|
670
|
+
await new Promise(resolve => {
|
|
671
|
+
const timer = setTimeout(resolve, delay);
|
|
672
|
+
timer.unref?.();
|
|
673
|
+
});
|
|
674
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
await this.loginFreshClient(reason, epoch);
|
|
679
|
+
if (this.desiredRunning && epoch === this.lifecycleEpoch) {
|
|
680
|
+
this.healthStatus = "connected";
|
|
681
|
+
this.emitHealthChanged();
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
|
|
687
|
+
break;
|
|
688
|
+
this.healthStatus = "retrying";
|
|
689
|
+
console.warn(`[discord:${this.id}] gateway rebuild attempt ${attempt + 1} failed: ${err.message}`);
|
|
690
|
+
this.emitHealthChanged();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
throw new Error("Discord gateway reconnect cancelled by adapter stop");
|
|
694
|
+
})().finally(() => {
|
|
695
|
+
if (this.reconnectPromise === reconnect)
|
|
696
|
+
this.reconnectPromise = null;
|
|
697
|
+
});
|
|
698
|
+
this.reconnectPromise = reconnect;
|
|
699
|
+
return reconnect;
|
|
700
|
+
}
|
|
701
|
+
async readyClient() {
|
|
702
|
+
// A few focused unit tests install a minimal REST-only client stand-in. Real
|
|
703
|
+
// discord.js Clients always expose isReady().
|
|
704
|
+
const isReady = typeof this.client.isReady === "function"
|
|
705
|
+
? this.client.isReady()
|
|
706
|
+
: true;
|
|
707
|
+
if (isReady && !this.reconnectPromise)
|
|
708
|
+
return this.client;
|
|
709
|
+
const reconnect = this.reconnectPromise ?? this.reconnectGateway("outbound requested while gateway unavailable");
|
|
710
|
+
let timer;
|
|
711
|
+
try {
|
|
712
|
+
await Promise.race([
|
|
713
|
+
reconnect,
|
|
714
|
+
new Promise((_, reject) => {
|
|
715
|
+
timer = setTimeout(() => reject(new Error("Discord gateway reconnect timed out")), GATEWAY_RECONNECT_WAIT_MS);
|
|
716
|
+
timer.unref?.();
|
|
717
|
+
}),
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
if (timer)
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
}
|
|
724
|
+
if (!this.client.isReady())
|
|
725
|
+
throw new Error("Discord gateway is not ready");
|
|
726
|
+
return this.client;
|
|
727
|
+
}
|
|
728
|
+
startGatewayWatchdog() {
|
|
729
|
+
if (this.watchdogTimer)
|
|
730
|
+
clearInterval(this.watchdogTimer);
|
|
731
|
+
this.lastWatchdogTickAt = this.now();
|
|
732
|
+
this.watchdogTimer = setInterval(() => this.checkGatewayLiveness(), this.watchdogIntervalMs);
|
|
733
|
+
this.watchdogTimer.unref?.();
|
|
734
|
+
}
|
|
735
|
+
checkGatewayLiveness() {
|
|
736
|
+
if (!this.desiredRunning || this.reconnectPromise)
|
|
737
|
+
return;
|
|
738
|
+
const now = this.now();
|
|
739
|
+
const elapsed = now - this.lastWatchdogTickAt;
|
|
740
|
+
this.lastWatchdogTickAt = now;
|
|
741
|
+
// Host suspend / an overloaded event loop makes wall-clock ACK age jump. Give
|
|
742
|
+
// discord.js a fresh heartbeat window instead of rebuilding immediately.
|
|
743
|
+
if (elapsed > this.watchdogIntervalMs * 3) {
|
|
744
|
+
this.generationReadyAt = now;
|
|
745
|
+
this.generationNonReadySince = now;
|
|
746
|
+
this.shardNonReadySince.clear();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const shards = [...this.client.ws.shards.values()];
|
|
750
|
+
let staleReason = null;
|
|
751
|
+
if (!this.client.isReady() && this.generationNonReadySince === 0)
|
|
752
|
+
this.generationNonReadySince = now;
|
|
753
|
+
if (this.client.isReady())
|
|
754
|
+
this.generationNonReadySince = 0;
|
|
755
|
+
if (!this.client.isReady() && now - this.generationNonReadySince > this.staleThresholdMs) {
|
|
756
|
+
staleReason = `client non-ready for ${now - this.generationNonReadySince}ms`;
|
|
757
|
+
}
|
|
758
|
+
for (const shard of shards) {
|
|
759
|
+
const lastAck = shard.lastPingTimestamp;
|
|
760
|
+
const previous = this.previousHeartbeatAck.get(shard.id);
|
|
761
|
+
if (lastAck >= 0 && previous != null && lastAck > previous) {
|
|
762
|
+
const interval = lastAck - previous;
|
|
763
|
+
if (interval >= 5_000 && interval <= 120_000)
|
|
764
|
+
this.observedHeartbeatInterval.set(shard.id, interval);
|
|
765
|
+
}
|
|
766
|
+
if (lastAck >= 0)
|
|
767
|
+
this.previousHeartbeatAck.set(shard.id, lastAck);
|
|
768
|
+
const threshold = Math.max(this.staleThresholdMs, 3 * (this.observedHeartbeatInterval.get(shard.id) ?? 0));
|
|
769
|
+
if (shard.status === Status.Ready) {
|
|
770
|
+
this.shardNonReadySince.delete(shard.id);
|
|
771
|
+
if (lastAck === -1) {
|
|
772
|
+
if (this.generationReadyAt > 0 && now - this.generationReadyAt > threshold) {
|
|
773
|
+
staleReason = `shard ${shard.id} has no heartbeat ACK after startup grace`;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (now - lastAck > threshold) {
|
|
777
|
+
staleReason = `shard ${shard.id} heartbeat ACK stale by ${now - lastAck}ms`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const since = this.shardNonReadySince.get(shard.id) ?? now;
|
|
782
|
+
this.shardNonReadySince.set(shard.id, since);
|
|
783
|
+
if (now - since > threshold)
|
|
784
|
+
staleReason = `shard ${shard.id} status ${shard.status} for ${now - since}ms`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (staleReason) {
|
|
788
|
+
this.healthStatus = "stale";
|
|
789
|
+
this.emitHealthChanged();
|
|
790
|
+
console.warn(`[discord:${this.id}] gateway watchdog detected ${staleReason}`);
|
|
791
|
+
void this.reconnectGateway(`watchdog: ${staleReason}`).catch(() => { });
|
|
792
|
+
}
|
|
793
|
+
else if (this.client.isReady()
|
|
794
|
+
&& shards.every(shard => shard.status === Status.Ready)
|
|
795
|
+
&& this.healthStatus !== "connected") {
|
|
796
|
+
// discord.js owns the first recovery layer (RESUME + replay). Clear a
|
|
797
|
+
// transient error marker once ACK/status prove that native recovery won.
|
|
798
|
+
this.healthStatus = "connected";
|
|
799
|
+
this.emitHealthChanged();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
getHealthSnapshot() {
|
|
803
|
+
const now = this.now();
|
|
804
|
+
const shards = [...this.client.ws.shards.values()].map(shard => {
|
|
805
|
+
const lastAck = shard.lastPingTimestamp >= 0 ? shard.lastPingTimestamp : null;
|
|
806
|
+
return {
|
|
807
|
+
id: shard.id,
|
|
808
|
+
status: shard.status,
|
|
809
|
+
lastHeartbeatAckAt: lastAck,
|
|
810
|
+
heartbeatAgeMs: lastAck == null ? null : Math.max(0, now - lastAck),
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
const heartbeatAcks = shards
|
|
814
|
+
.map(shard => shard.lastHeartbeatAckAt)
|
|
815
|
+
.filter((value) => value != null);
|
|
816
|
+
const lastHeartbeatAckAt = heartbeatAcks.length > 0 ? Math.min(...heartbeatAcks) : null;
|
|
817
|
+
return {
|
|
818
|
+
id: this.id,
|
|
819
|
+
type: this.type,
|
|
820
|
+
status: this.healthStatus,
|
|
821
|
+
generation: this.clientGeneration,
|
|
822
|
+
isReady: this.client.isReady(),
|
|
823
|
+
wsStatus: this.client.ws.status ?? null,
|
|
824
|
+
lastHeartbeatAckAt,
|
|
825
|
+
heartbeatAgeMs: lastHeartbeatAckAt == null ? null : Math.max(0, now - lastHeartbeatAckAt),
|
|
826
|
+
shards,
|
|
827
|
+
lastDispatchAt: this.lastDispatchAt,
|
|
828
|
+
lastReconnectAt: this.lastReconnectAt,
|
|
829
|
+
lastReconnectReason: this.lastReconnectReason,
|
|
830
|
+
reconnectCount: this.reconnectCount,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async handleClientReady(client, generation) {
|
|
834
|
+
if (!this.isCurrentClient(client, generation))
|
|
835
|
+
return;
|
|
836
|
+
this.healthStatus = "connected";
|
|
837
|
+
this.generationReadyAt = this.now();
|
|
838
|
+
this.generationNonReadySince = 0;
|
|
839
|
+
this.shardNonReadySince.clear();
|
|
840
|
+
try {
|
|
517
841
|
// Register classic bot slash commands (skipped for a secondary bot sharing
|
|
518
842
|
// a guild with the primary — only the primary owns the guild's commands).
|
|
519
843
|
if (this.registerCommands)
|
|
520
844
|
try {
|
|
521
|
-
await
|
|
845
|
+
await client.application?.commands.set([
|
|
522
846
|
{
|
|
523
847
|
name: "start", description: t("slash.start"),
|
|
524
848
|
options: [{
|
|
@@ -647,11 +971,40 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
647
971
|
catch (err) {
|
|
648
972
|
// Non-fatal — slash commands may fail on network issues
|
|
649
973
|
}
|
|
650
|
-
this.
|
|
651
|
-
|
|
652
|
-
|
|
974
|
+
if (!this.isCurrentClient(client, generation))
|
|
975
|
+
return;
|
|
976
|
+
this.emit("started", client.user?.username ?? "discord-bot", client.user?.id);
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
console.warn(`[discord:${this.id}] ready handler failed: ${err.message}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async start() {
|
|
983
|
+
if (this.desiredRunning && this.client.isReady())
|
|
984
|
+
return;
|
|
985
|
+
this.desiredRunning = true;
|
|
986
|
+
const epoch = ++this.lifecycleEpoch;
|
|
987
|
+
this.healthStatus = "starting";
|
|
988
|
+
this.queue.start();
|
|
989
|
+
try {
|
|
990
|
+
await this.loginFreshClient("startup", epoch);
|
|
991
|
+
this.startGatewayWatchdog();
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
if (epoch === this.lifecycleEpoch) {
|
|
995
|
+
this.desiredRunning = false;
|
|
996
|
+
this.healthStatus = "stopped";
|
|
997
|
+
}
|
|
998
|
+
throw err;
|
|
999
|
+
}
|
|
653
1000
|
}
|
|
654
1001
|
async stop() {
|
|
1002
|
+
this.desiredRunning = false;
|
|
1003
|
+
this.lifecycleEpoch++;
|
|
1004
|
+
this.healthStatus = "stopped";
|
|
1005
|
+
if (this.watchdogTimer)
|
|
1006
|
+
clearInterval(this.watchdogTimer);
|
|
1007
|
+
this.watchdogTimer = null;
|
|
655
1008
|
this.queue.stop();
|
|
656
1009
|
this.client.destroy();
|
|
657
1010
|
}
|
|
@@ -718,7 +1071,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
718
1071
|
}
|
|
719
1072
|
catch { /* not in that channel — fall through to scan */ }
|
|
720
1073
|
try {
|
|
721
|
-
const guild = await this.
|
|
1074
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
722
1075
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
723
1076
|
for (const [, ch] of channels) {
|
|
724
1077
|
try {
|
|
@@ -759,7 +1112,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
759
1112
|
}
|
|
760
1113
|
catch { /* not in that channel — fall through to scan */ }
|
|
761
1114
|
try {
|
|
762
|
-
const guild = await this.
|
|
1115
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
763
1116
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
764
1117
|
for (const [, ch] of channels) {
|
|
765
1118
|
try {
|
|
@@ -786,7 +1139,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
786
1139
|
}
|
|
787
1140
|
catch { /* not in that channel — fall through to scan */ }
|
|
788
1141
|
try {
|
|
789
|
-
const guild = await this.
|
|
1142
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
790
1143
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
791
1144
|
for (const [, ch] of channels) {
|
|
792
1145
|
try {
|
|
@@ -809,7 +1162,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
809
1162
|
const encoded = encodeURIComponent(emoji);
|
|
810
1163
|
try {
|
|
811
1164
|
// Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
|
|
812
|
-
await this.
|
|
1165
|
+
await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
|
|
813
1166
|
return;
|
|
814
1167
|
}
|
|
815
1168
|
catch (directError) {
|
|
@@ -977,7 +1330,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
977
1330
|
}
|
|
978
1331
|
// ── Topology: create channel ────────────────────────────────────────────
|
|
979
1332
|
async _resolveCategory() {
|
|
980
|
-
const guild = await this.
|
|
1333
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
981
1334
|
await guild.channels.fetch();
|
|
982
1335
|
const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
|
|
983
1336
|
if (existing)
|
|
@@ -998,7 +1351,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
998
1351
|
return this.categoryIdPromise;
|
|
999
1352
|
}
|
|
1000
1353
|
async createTopic(name) {
|
|
1001
|
-
const guild = await this.
|
|
1354
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
1002
1355
|
const categoryId = await this.ensureCategoryId();
|
|
1003
1356
|
try {
|
|
1004
1357
|
const channel = await guild.channels.create({
|
|
@@ -1024,7 +1377,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
1024
1377
|
}
|
|
1025
1378
|
}
|
|
1026
1379
|
async deleteTopic(topicId) {
|
|
1027
|
-
const channel = await this.
|
|
1380
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
1028
1381
|
// Only delete GuildText channels created by createTopic — never categories or forums
|
|
1029
1382
|
if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
|
|
1030
1383
|
await channel.delete();
|
|
@@ -1032,7 +1385,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
1032
1385
|
}
|
|
1033
1386
|
async topicExists(topicId) {
|
|
1034
1387
|
try {
|
|
1035
|
-
const channel = await this.
|
|
1388
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
1036
1389
|
return channel != null;
|
|
1037
1390
|
}
|
|
1038
1391
|
catch {
|