@songsid/agend 2.1.4-beta.4 → 2.1.4-beta.41
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/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +9 -5
- package/dist/backend/antigravity.js +156 -31
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +62 -23
- package/dist/backend/claude-code.js +296 -43
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +148 -8
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.js +2 -2
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.js +2 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +2 -0
- package/dist/backend/kiro.js +24 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +27 -11
- package/dist/backend/opencode.js +65 -41
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +45 -3
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +44 -2
- package/dist/channel/adapters/discord.js +526 -78
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +30 -0
- package/dist/channel/adapters/telegram.js +212 -18
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/agy-mcp-launcher.d.ts +2 -0
- package/dist/channel/agy-mcp-launcher.js +28 -0
- package/dist/channel/agy-mcp-launcher.js.map +1 -0
- package/dist/channel/ipc-bridge.d.ts +2 -1
- package/dist/channel/ipc-bridge.js +33 -4
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-server.js +28 -25
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js +62 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/types.d.ts +31 -1
- package/dist/classic-channel-manager.d.ts +40 -3
- package/dist/classic-channel-manager.js +218 -31
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +109 -37
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +48 -7
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +6 -0
- package/dist/cross-instance-envelope.js +30 -0
- package/dist/cross-instance-envelope.js.map +1 -0
- package/dist/daemon.d.ts +319 -9
- package/dist/daemon.js +1402 -255
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +41 -0
- package/dist/doctor.js +267 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fleet-context.d.ts +47 -0
- package/dist/fleet-manager.d.ts +265 -11
- package/dist/fleet-manager.js +2273 -455
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-yaml-slim.d.ts +10 -0
- package/dist/fleet-yaml-slim.js +59 -0
- package/dist/fleet-yaml-slim.js.map +1 -0
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
- package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
- package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
- package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
- package/dist/instance-lifecycle.d.ts +67 -0
- package/dist/instance-lifecycle.js +273 -8
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +35 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +966 -288
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +91 -0
- package/dist/login-flows.js +170 -0
- package/dist/login-flows.js.map +1 -0
- package/dist/login-manager.d.ts +63 -0
- package/dist/login-manager.js +134 -0
- package/dist/login-manager.js.map +1 -0
- package/dist/network-family.d.ts +18 -0
- package/dist/network-family.js +20 -0
- package/dist/network-family.js.map +1 -0
- package/dist/outbound-handlers.d.ts +8 -0
- package/dist/outbound-handlers.js +101 -18
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -1
- package/dist/outbound-schemas.js +9 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/reply-dedup.d.ts +7 -8
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-progress.d.ts +2 -0
- package/dist/restart-progress.js +3 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +12 -0
- package/dist/scheduler/db.js +59 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-installer.d.ts +11 -0
- package/dist/service-installer.js +84 -18
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.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/tips.d.ts +44 -0
- package/dist/tips.js +355 -0
- package/dist/tips.js.map +1 -0
- package/dist/tmux-manager.d.ts +16 -0
- package/dist/tmux-manager.js +110 -25
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-progress.d.ts +40 -0
- package/dist/tool-progress.js +289 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/topic-commands.d.ts +42 -4
- package/dist/topic-commands.js +361 -67
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.d.ts +15 -2
- package/dist/transcript-monitor.js +63 -17
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +131 -0
- package/dist/transcript-sources.js +580 -0
- package/dist/transcript-sources.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +200 -60
- package/dist/ui/view.html +147 -30
- package/dist/usage/format-rich.js +24 -23
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +7 -0
- package/dist/usage/i18n-keys.js +34 -0
- package/dist/usage/i18n-keys.js.map +1 -0
- package/dist/usage/i18n.d.ts +5 -0
- package/dist/usage/i18n.js +27 -0
- package/dist/usage/i18n.js.map +1 -0
- package/dist/usage/providers.d.ts +19 -0
- package/dist/usage/providers.js +150 -74
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +8 -2
- package/dist/usage/usage-api.js +45 -23
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/workflow-templates/default.md +2 -1
- package/package.json +2 -2
|
@@ -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;
|
|
@@ -265,6 +350,33 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
265
350
|
size: att.size,
|
|
266
351
|
filename: att.name ?? undefined,
|
|
267
352
|
}));
|
|
353
|
+
// A Discord reply carries only the referenced message id on the new
|
|
354
|
+
// message. Fetch the original so image attachments are delivered with
|
|
355
|
+
// the reply rather than reducing the reference to text alone. Keep this
|
|
356
|
+
// limited to real attachments: ordinary URL auto-embeds must not become
|
|
357
|
+
// downloads (the same policy as the outer message above).
|
|
358
|
+
let replyToText;
|
|
359
|
+
if (msg.reference?.messageId) {
|
|
360
|
+
try {
|
|
361
|
+
const ref = await msg.fetchReference();
|
|
362
|
+
replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
|
|
363
|
+
for (const att of ref.attachments?.values() ?? []) {
|
|
364
|
+
if (!att.contentType?.startsWith("image/"))
|
|
365
|
+
continue;
|
|
366
|
+
if (!attachments.some(existing => existing.fileId === att.id)) {
|
|
367
|
+
attachments.push({
|
|
368
|
+
kind: "photo",
|
|
369
|
+
fileId: att.id,
|
|
370
|
+
mime: att.contentType,
|
|
371
|
+
size: att.size,
|
|
372
|
+
filename: att.name ?? undefined,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
this.attachmentUrls.set(att.id, att.url);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch { /* deleted message or no permission */ }
|
|
379
|
+
}
|
|
268
380
|
// Embed images become synthetic photo attachments so the normal download
|
|
269
381
|
// path (attachment-handler → downloadAttachment → image_path) serves them
|
|
270
382
|
// without knowing where the bytes came from. Their synthetic ids are
|
|
@@ -293,15 +405,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
293
405
|
else
|
|
294
406
|
break;
|
|
295
407
|
}
|
|
296
|
-
|
|
297
|
-
if (msg.reference?.messageId) {
|
|
298
|
-
try {
|
|
299
|
-
const ref = await msg.fetchReference();
|
|
300
|
-
replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
|
|
301
|
-
}
|
|
302
|
-
catch { /* deleted message or no permission */ }
|
|
303
|
-
}
|
|
304
|
-
this.emit("message", {
|
|
408
|
+
this.emitFromClient(client, generation, "message", {
|
|
305
409
|
source: "discord",
|
|
306
410
|
adapterId: this.id,
|
|
307
411
|
chatId,
|
|
@@ -326,8 +430,10 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
326
430
|
// Handle button interactions and slash commands
|
|
327
431
|
// Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
|
|
328
432
|
// interaction expires (>3s). Catch to prevent crashing the entire daemon.
|
|
329
|
-
|
|
433
|
+
client.on("interactionCreate", async (interaction) => {
|
|
330
434
|
try {
|
|
435
|
+
if (!this.isCurrentClient(client, generation))
|
|
436
|
+
return;
|
|
331
437
|
// Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
|
|
332
438
|
// A button has a 3s ack window; any early return (unknown guild/channel)
|
|
333
439
|
// or a downstream no-op (e.g. the cancel button was already cleared) would
|
|
@@ -344,7 +450,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
344
450
|
// console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
|
|
345
451
|
return;
|
|
346
452
|
}
|
|
347
|
-
this.
|
|
453
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
348
454
|
callbackData: interaction.customId,
|
|
349
455
|
chatId: this.guildId,
|
|
350
456
|
threadId: interaction.channelId,
|
|
@@ -372,7 +478,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
372
478
|
const callbackData = interaction.values[0];
|
|
373
479
|
if (!callbackData)
|
|
374
480
|
return;
|
|
375
|
-
this.
|
|
481
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
376
482
|
callbackData,
|
|
377
483
|
chatId: this.guildId,
|
|
378
484
|
threadId: interaction.channelId,
|
|
@@ -392,7 +498,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
392
498
|
if (interaction.commandName === "chat") {
|
|
393
499
|
const text = interaction.options.getString("message") ?? "";
|
|
394
500
|
await interaction.deferReply();
|
|
395
|
-
this.
|
|
501
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
396
502
|
command: "chat",
|
|
397
503
|
channelId: interaction.channelId,
|
|
398
504
|
channelName,
|
|
@@ -419,7 +525,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
419
525
|
for (const opt of interaction.options.data) {
|
|
420
526
|
options[opt.name] = opt.value;
|
|
421
527
|
}
|
|
422
|
-
this.
|
|
528
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
423
529
|
command: interaction.commandName,
|
|
424
530
|
channelId: interaction.channelId,
|
|
425
531
|
channelName,
|
|
@@ -433,6 +539,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
433
539
|
catch {
|
|
434
540
|
return undefined;
|
|
435
541
|
} },
|
|
542
|
+
dismissResponse: async () => {
|
|
543
|
+
try {
|
|
544
|
+
await interaction.deleteReply();
|
|
545
|
+
}
|
|
546
|
+
catch { /* interaction may already be gone */ }
|
|
547
|
+
},
|
|
436
548
|
respondChoices: async (text, choices) => {
|
|
437
549
|
const select = new StringSelectMenuBuilder()
|
|
438
550
|
.setCustomId(CLASSIC_START_BACKEND_SELECT_ID)
|
|
@@ -459,14 +571,18 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
459
571
|
}
|
|
460
572
|
});
|
|
461
573
|
// Handle channel deletion (equivalent to topic_closed)
|
|
462
|
-
|
|
463
|
-
this.
|
|
574
|
+
client.on("guildCreate", (guild) => {
|
|
575
|
+
if (!this.isCurrentClient(client, generation))
|
|
576
|
+
return;
|
|
577
|
+
this.emitFromClient(client, generation, "new_group_detected", {
|
|
464
578
|
groupId: guild.id,
|
|
465
579
|
groupTitle: guild.name,
|
|
466
580
|
source: "discord",
|
|
467
581
|
});
|
|
468
582
|
});
|
|
469
|
-
|
|
583
|
+
client.on("channelDelete", (channel) => {
|
|
584
|
+
if (!this.isCurrentClient(client, generation))
|
|
585
|
+
return;
|
|
470
586
|
if (!("guildId" in channel))
|
|
471
587
|
return;
|
|
472
588
|
if (channel.guildId !== this.guildId) {
|
|
@@ -474,7 +590,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
474
590
|
return;
|
|
475
591
|
// Allowed: an open classic channel in a non-primary guild was deleted.
|
|
476
592
|
}
|
|
477
|
-
this.
|
|
593
|
+
this.emitFromClient(client, generation, "topic_closed", {
|
|
478
594
|
chatId: this.guildId,
|
|
479
595
|
threadId: channel.id,
|
|
480
596
|
});
|
|
@@ -486,19 +602,252 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
486
602
|
// console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
|
|
487
603
|
}
|
|
488
604
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 {
|
|
492
841
|
// Register classic bot slash commands (skipped for a secondary bot sharing
|
|
493
842
|
// a guild with the primary — only the primary owns the guild's commands).
|
|
494
843
|
if (this.registerCommands)
|
|
495
844
|
try {
|
|
496
|
-
await
|
|
845
|
+
await client.application?.commands.set([
|
|
497
846
|
{
|
|
498
847
|
name: "start", description: t("slash.start"),
|
|
499
848
|
options: [{
|
|
500
849
|
name: "backend",
|
|
501
|
-
description: "
|
|
850
|
+
description: t("slash.option.backend"),
|
|
502
851
|
type: ApplicationCommandOptionType.String,
|
|
503
852
|
required: true,
|
|
504
853
|
choices: DISCORD_START_BACKEND_CHOICES,
|
|
@@ -506,16 +855,16 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
506
855
|
},
|
|
507
856
|
{ name: "stop", description: t("slash.stop") },
|
|
508
857
|
{
|
|
509
|
-
name: "pause", description: "🔒
|
|
510
|
-
options: [{ name: "instance", description: "
|
|
858
|
+
name: "pause", description: "🔒 " + t("slash.pause"),
|
|
859
|
+
options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
|
|
511
860
|
},
|
|
512
861
|
{
|
|
513
|
-
name: "wake", description: "🔒
|
|
514
|
-
options: [{ name: "instance", description: "
|
|
862
|
+
name: "wake", description: "🔒 " + t("slash.wake"),
|
|
863
|
+
options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
|
|
515
864
|
},
|
|
516
865
|
{
|
|
517
866
|
name: "chat", description: t("slash.chat"),
|
|
518
|
-
options: [{ name: "message", description: "
|
|
867
|
+
options: [{ name: "message", description: t("slash.option.message"), type: 3, required: true }],
|
|
519
868
|
},
|
|
520
869
|
{ name: "status", description: "🔒 " + t("slash.status") },
|
|
521
870
|
{ name: "sysinfo", description: t("slash.sysinfo") },
|
|
@@ -525,15 +874,77 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
525
874
|
{ name: "update", description: "🔒 " + t("slash.update") },
|
|
526
875
|
{ name: "doctor", description: "🔒 " + t("slash.doctor") },
|
|
527
876
|
{ name: "usage", description: t("slash.usage") },
|
|
877
|
+
{
|
|
878
|
+
name: "tips", description: t("slash.tips"),
|
|
879
|
+
options: [{
|
|
880
|
+
name: "mode", description: t("slash.option.tips_mode"),
|
|
881
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
882
|
+
choices: [
|
|
883
|
+
{ name: "on", value: "on" },
|
|
884
|
+
{ name: "off", value: "off" },
|
|
885
|
+
{ name: "advanced on", value: "advanced on" },
|
|
886
|
+
],
|
|
887
|
+
}],
|
|
888
|
+
},
|
|
528
889
|
{ name: "compact", description: "🔒 " + t("slash.compact") },
|
|
890
|
+
{
|
|
891
|
+
name: "steer", description: t("slash.steer"),
|
|
892
|
+
options: [{
|
|
893
|
+
name: "message", description: t("slash.option.steer_message"),
|
|
894
|
+
type: ApplicationCommandOptionType.String, required: true,
|
|
895
|
+
}],
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
name: "btw", description: t("slash.btw"),
|
|
899
|
+
options: [{
|
|
900
|
+
name: "message", description: t("slash.option.btw_message"),
|
|
901
|
+
type: ApplicationCommandOptionType.String, required: true,
|
|
902
|
+
}],
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
name: "login", description: "🔒 " + t("slash.login"),
|
|
906
|
+
options: [
|
|
907
|
+
{
|
|
908
|
+
name: "backend", description: t("slash.option.login_backend"),
|
|
909
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
910
|
+
choices: [
|
|
911
|
+
{ name: "claude-code", value: "claude-code" },
|
|
912
|
+
{ name: "codex", value: "codex" },
|
|
913
|
+
{ name: "kiro-cli", value: "kiro-cli" },
|
|
914
|
+
{ name: "grok", value: "grok" },
|
|
915
|
+
{ name: "antigravity", value: "antigravity" },
|
|
916
|
+
],
|
|
917
|
+
},
|
|
918
|
+
{ name: "code", description: t("slash.option.login_code"), type: ApplicationCommandOptionType.String, required: false },
|
|
919
|
+
{ name: "cancel", description: t("slash.option.login_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
|
|
920
|
+
],
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
name: "install-cli", description: "🔒 " + t("slash.install_cli"),
|
|
924
|
+
options: [
|
|
925
|
+
{
|
|
926
|
+
name: "backend", description: t("slash.option.install_backend"),
|
|
927
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
928
|
+
choices: [
|
|
929
|
+
{ name: "claude-code", value: "claude-code" },
|
|
930
|
+
{ name: "codex", value: "codex" },
|
|
931
|
+
{ name: "kiro-cli", value: "kiro-cli" },
|
|
932
|
+
{ name: "grok", value: "grok" },
|
|
933
|
+
{ name: "antigravity", value: "antigravity" },
|
|
934
|
+
{ name: "opencode", value: "opencode" },
|
|
935
|
+
],
|
|
936
|
+
},
|
|
937
|
+
{ name: "cancel", description: t("slash.option.install_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
|
|
938
|
+
],
|
|
939
|
+
},
|
|
529
940
|
{ name: "clear", description: "🔒 " + t("slash.clear") },
|
|
530
941
|
{
|
|
531
|
-
name: "model", description: "🔒
|
|
942
|
+
name: "model", description: "🔒 " + t("slash.model"),
|
|
532
943
|
},
|
|
533
944
|
{
|
|
534
945
|
name: "effort", description: "🔒 " + t("slash.effort"),
|
|
535
946
|
options: [{
|
|
536
|
-
name: "level", description: "
|
|
947
|
+
name: "level", description: t("slash.option.effort_level"),
|
|
537
948
|
type: ApplicationCommandOptionType.String, required: false,
|
|
538
949
|
choices: [
|
|
539
950
|
{ name: "low", value: "low" }, { name: "medium", value: "medium" },
|
|
@@ -546,13 +957,13 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
546
957
|
{
|
|
547
958
|
name: "save", description: "🔒 " + t("slash.save"),
|
|
548
959
|
options: [
|
|
549
|
-
{ name: "filename", description: "
|
|
550
|
-
{ name: "force", description: "
|
|
960
|
+
{ name: "filename", description: t("slash.option.save_filename"), type: 3, required: true },
|
|
961
|
+
{ name: "force", description: t("slash.option.save_force"), type: 5, required: false },
|
|
551
962
|
],
|
|
552
963
|
},
|
|
553
964
|
{
|
|
554
965
|
name: "load", description: "🔒 " + t("slash.load"),
|
|
555
|
-
options: [{ name: "filename", description: "
|
|
966
|
+
options: [{ name: "filename", description: t("slash.option.load_filename"), type: 3, required: true }],
|
|
556
967
|
},
|
|
557
968
|
{ name: "cancel", description: t("slash.cancel") },
|
|
558
969
|
]);
|
|
@@ -560,11 +971,40 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
560
971
|
catch (err) {
|
|
561
972
|
// Non-fatal — slash commands may fail on network issues
|
|
562
973
|
}
|
|
563
|
-
this.
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|
|
566
1000
|
}
|
|
567
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;
|
|
568
1008
|
this.queue.stop();
|
|
569
1009
|
this.client.destroy();
|
|
570
1010
|
}
|
|
@@ -597,13 +1037,15 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
597
1037
|
const chunks = splitText(text, chunkLimit);
|
|
598
1038
|
if (chunks.length === 0)
|
|
599
1039
|
throw new Error("Empty text");
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1040
|
+
// Await every platform POST before reporting success. Previously only the
|
|
1041
|
+
// first chunk was awaited and later chunks were fire-and-forget queue items,
|
|
1042
|
+
// so the reply tool could return success for a silently truncated message.
|
|
1043
|
+
let first;
|
|
1044
|
+
for (const chunk of chunks) {
|
|
1045
|
+
const sent = await channel.send(opts?.disablePreview
|
|
1046
|
+
? { content: chunk, flags: MessageFlags.SuppressEmbeds }
|
|
1047
|
+
: chunk);
|
|
1048
|
+
first ??= sent;
|
|
607
1049
|
}
|
|
608
1050
|
return {
|
|
609
1051
|
messageId: first.id,
|
|
@@ -629,7 +1071,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
629
1071
|
}
|
|
630
1072
|
catch { /* not in that channel — fall through to scan */ }
|
|
631
1073
|
try {
|
|
632
|
-
const guild = await this.
|
|
1074
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
633
1075
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
634
1076
|
for (const [, ch] of channels) {
|
|
635
1077
|
try {
|
|
@@ -653,6 +1095,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
653
1095
|
}
|
|
654
1096
|
/** Edit text and clear components (Discord keeps components on a plain edit,
|
|
655
1097
|
* so we must pass an empty array to drop the Cancel button). */
|
|
1098
|
+
/** Keyboard-only removal — Discord keeps the original content untouched. */
|
|
1099
|
+
async removeMessageButtons(chatId, messageId, threadId) {
|
|
1100
|
+
const channel = await this._fetchTextChannel(threadId ?? chatId);
|
|
1101
|
+
const msg = await channel.messages.fetch(messageId);
|
|
1102
|
+
await msg.edit({ components: [] });
|
|
1103
|
+
}
|
|
656
1104
|
async editMessageRemoveButtons(chatId, messageId, text, threadId) {
|
|
657
1105
|
// Prefer the exact channel (handles forum-topic threads, which a GuildText
|
|
658
1106
|
// scan misses); fall back to scanning top-level text channels.
|
|
@@ -664,7 +1112,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
664
1112
|
}
|
|
665
1113
|
catch { /* not in that channel — fall through to scan */ }
|
|
666
1114
|
try {
|
|
667
|
-
const guild = await this.
|
|
1115
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
668
1116
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
669
1117
|
for (const [, ch] of channels) {
|
|
670
1118
|
try {
|
|
@@ -691,7 +1139,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
691
1139
|
}
|
|
692
1140
|
catch { /* not in that channel — fall through to scan */ }
|
|
693
1141
|
try {
|
|
694
|
-
const guild = await this.
|
|
1142
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
695
1143
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
696
1144
|
for (const [, ch] of channels) {
|
|
697
1145
|
try {
|
|
@@ -714,7 +1162,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
714
1162
|
const encoded = encodeURIComponent(emoji);
|
|
715
1163
|
try {
|
|
716
1164
|
// Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
|
|
717
|
-
await this.
|
|
1165
|
+
await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
|
|
718
1166
|
return;
|
|
719
1167
|
}
|
|
720
1168
|
catch (directError) {
|
|
@@ -882,7 +1330,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
882
1330
|
}
|
|
883
1331
|
// ── Topology: create channel ────────────────────────────────────────────
|
|
884
1332
|
async _resolveCategory() {
|
|
885
|
-
const guild = await this.
|
|
1333
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
886
1334
|
await guild.channels.fetch();
|
|
887
1335
|
const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
|
|
888
1336
|
if (existing)
|
|
@@ -903,7 +1351,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
903
1351
|
return this.categoryIdPromise;
|
|
904
1352
|
}
|
|
905
1353
|
async createTopic(name) {
|
|
906
|
-
const guild = await this.
|
|
1354
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
907
1355
|
const categoryId = await this.ensureCategoryId();
|
|
908
1356
|
try {
|
|
909
1357
|
const channel = await guild.channels.create({
|
|
@@ -929,7 +1377,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
929
1377
|
}
|
|
930
1378
|
}
|
|
931
1379
|
async deleteTopic(topicId) {
|
|
932
|
-
const channel = await this.
|
|
1380
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
933
1381
|
// Only delete GuildText channels created by createTopic — never categories or forums
|
|
934
1382
|
if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
|
|
935
1383
|
await channel.delete();
|
|
@@ -937,7 +1385,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
937
1385
|
}
|
|
938
1386
|
async topicExists(topicId) {
|
|
939
1387
|
try {
|
|
940
|
-
const channel = await this.
|
|
1388
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
941
1389
|
return channel != null;
|
|
942
1390
|
}
|
|
943
1391
|
catch {
|