@songsid/agend 2.1.4-beta.9 → 2.1.5-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +2 -2
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +12 -8
- package/dist/backend/antigravity.js +189 -42
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +87 -23
- package/dist/backend/claude-code.js +361 -46
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +139 -5
- 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 +52 -2
- package/dist/backend/kiro.js +267 -12
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.js +17 -4
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +122 -3
- package/dist/backend/types.js +5 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-outage.d.ts +61 -0
- package/dist/backend-outage.js +71 -0
- package/dist/backend-outage.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +44 -2
- package/dist/channel/adapters/discord.js +523 -90
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +30 -0
- package/dist/channel/adapters/telegram.js +221 -19
- 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/markdown-chunk.d.ts +42 -0
- package/dist/channel/markdown-chunk.js +205 -0
- package/dist/channel/markdown-chunk.js.map +1 -0
- package/dist/channel/mcp-server.js +34 -5
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js +64 -8
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/types.d.ts +31 -1
- package/dist/classic-channel-manager.d.ts +65 -0
- package/dist/classic-channel-manager.js +172 -3
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +124 -83
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +13 -0
- 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 +491 -14
- package/dist/daemon.js +2121 -276
- 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 +63 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +402 -11
- package/dist/fleet-manager.js +2594 -422
- 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/cross-instance-messaging/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +63 -16
- package/dist/general-knowledge/skills/session-management/SKILL.md +171 -8
- package/dist/instance-lifecycle.d.ts +114 -1
- package/dist/instance-lifecycle.js +404 -14
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +42 -4
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +1024 -320
- 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 +16 -1
- package/dist/outbound-handlers.js +174 -40
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +10 -3
- package/dist/outbound-schemas.js +10 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +52 -0
- package/dist/pane-input-residue.js +107 -0
- package/dist/pane-input-residue.js.map +1 -0
- package/dist/process-memory.d.ts +6 -0
- package/dist/process-memory.js +45 -0
- package/dist/process-memory.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/steer-capability.d.ts +1 -0
- package/dist/steer-capability.js +6 -0
- package/dist/steer-capability.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/topic-commands.d.ts +31 -7
- package/dist/topic-commands.js +342 -70
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +35 -10
- package/dist/transcript-sources.js +190 -38
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +200 -60
- package/dist/ui/view.html +147 -30
- package/dist/ui/web-terminal/terminal.css +17 -0
- package/dist/ui/web-terminal/terminal.html +43 -0
- package/dist/ui/web-terminal/terminal.js +148 -0
- package/dist/ui/web-terminal/vendor/LICENSE.addon-fit +19 -0
- package/dist/ui/web-terminal/vendor/LICENSE.addon-web-links +19 -0
- package/dist/ui/web-terminal/vendor/LICENSE.xterm +21 -0
- package/dist/ui/web-terminal/vendor/VENDORED.md +22 -0
- package/dist/ui/web-terminal/vendor/addon-fit.js +1 -0
- package/dist/ui/web-terminal/vendor/addon-web-links.js +1 -0
- package/dist/ui/web-terminal/vendor/xterm.css +285 -0
- package/dist/ui/web-terminal/vendor/xterm.js +1 -0
- package/dist/usage/format-rich.d.ts +1 -1
- package/dist/usage/format-rich.js +28 -24
- 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 +21 -0
- package/dist/usage/providers.js +153 -75
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +11 -3
- package/dist/usage/usage-api.js +61 -24
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-terminal-http.d.ts +53 -0
- package/dist/web-terminal-http.js +396 -0
- package/dist/web-terminal-http.js.map +1 -0
- package/dist/web-terminal.d.ts +329 -0
- package/dist/web-terminal.js +833 -0
- package/dist/web-terminal.js.map +1 -0
- package/dist/ws-server.d.ts +69 -0
- package/dist/ws-server.js +392 -0
- package/dist/ws-server.js.map +1 -0
- package/package.json +2 -2
|
@@ -6,9 +6,14 @@ 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
|
+
import { splitTextFenceAware } from "../markdown-chunk.js";
|
|
11
12
|
const DISCORD_MAX_LENGTH = 2000;
|
|
13
|
+
const GATEWAY_WATCHDOG_INTERVAL_MS = 30_000;
|
|
14
|
+
const GATEWAY_STALE_MIN_MS = 180_000;
|
|
15
|
+
const GATEWAY_RECONNECT_WAIT_MS = 30_000;
|
|
16
|
+
const GATEWAY_MAX_BACKOFF_MS = 5 * 60_000;
|
|
12
17
|
/** Curated ClassicBot backends for Discord's native slash-option dropdown. */
|
|
13
18
|
export const DISCORD_START_BACKEND_CHOICES = [
|
|
14
19
|
{ name: "Claude Code", value: "claude-code" },
|
|
@@ -24,6 +29,27 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
24
29
|
topology = "channels";
|
|
25
30
|
id;
|
|
26
31
|
client;
|
|
32
|
+
clientFactory;
|
|
33
|
+
now;
|
|
34
|
+
watchdogIntervalMs;
|
|
35
|
+
staleThresholdMs;
|
|
36
|
+
reconnectBaseDelayMs;
|
|
37
|
+
desiredRunning = false;
|
|
38
|
+
lifecycleEpoch = 0;
|
|
39
|
+
clientGeneration = 0;
|
|
40
|
+
reconnectPromise = null;
|
|
41
|
+
watchdogTimer = null;
|
|
42
|
+
lastWatchdogTickAt = 0;
|
|
43
|
+
generationReadyAt = 0;
|
|
44
|
+
generationNonReadySince = 0;
|
|
45
|
+
shardNonReadySince = new Map();
|
|
46
|
+
previousHeartbeatAck = new Map();
|
|
47
|
+
observedHeartbeatInterval = new Map();
|
|
48
|
+
healthStatus = "stopped";
|
|
49
|
+
lastDispatchAt = null;
|
|
50
|
+
lastReconnectAt = null;
|
|
51
|
+
lastReconnectReason = null;
|
|
52
|
+
reconnectCount = 0;
|
|
27
53
|
botToken;
|
|
28
54
|
accessManager;
|
|
29
55
|
inboxDir;
|
|
@@ -47,20 +73,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
47
73
|
this.generalChannelId = opts.generalChannelId;
|
|
48
74
|
this.registerCommands = opts.registerCommands !== false;
|
|
49
75
|
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
|
-
});
|
|
76
|
+
this.clientFactory = opts.clientFactory ?? (() => this.buildClient());
|
|
77
|
+
this.now = opts.now ?? Date.now;
|
|
78
|
+
this.watchdogIntervalMs = opts.watchdogIntervalMs ?? GATEWAY_WATCHDOG_INTERVAL_MS;
|
|
79
|
+
this.staleThresholdMs = opts.staleThresholdMs ?? GATEWAY_STALE_MIN_MS;
|
|
80
|
+
this.reconnectBaseDelayMs = opts.reconnectBaseDelayMs ?? 5_000;
|
|
81
|
+
this.client = this.clientFactory();
|
|
64
82
|
this.queue = new MessageQueue({
|
|
65
83
|
send: async (chatId, threadId, text) => {
|
|
66
84
|
const channel = await this._fetchTextChannel(threadId ?? chatId);
|
|
@@ -78,23 +96,87 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
78
96
|
return { messageId: msg.id };
|
|
79
97
|
},
|
|
80
98
|
});
|
|
81
|
-
this.
|
|
99
|
+
this.registerClientHandlers(this.client, this.clientGeneration);
|
|
100
|
+
}
|
|
101
|
+
buildClient() {
|
|
102
|
+
return new Client({
|
|
103
|
+
intents: [
|
|
104
|
+
GatewayIntentBits.Guilds,
|
|
105
|
+
GatewayIntentBits.GuildMessages,
|
|
106
|
+
GatewayIntentBits.MessageContent,
|
|
107
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
108
|
+
],
|
|
109
|
+
// Events for messages/reactions created before this process started are partial.
|
|
110
|
+
partials: [Partials.Message, Partials.Reaction, Partials.User],
|
|
111
|
+
});
|
|
82
112
|
}
|
|
83
113
|
async _fetchTextChannel(channelId) {
|
|
84
|
-
const
|
|
114
|
+
const client = await this.readyClient();
|
|
115
|
+
const channel = await client.channels.fetch(channelId);
|
|
85
116
|
if (!channel?.isTextBased()) {
|
|
86
117
|
throw new Error(`Channel ${channelId} is not a text channel`);
|
|
87
118
|
}
|
|
88
119
|
return channel;
|
|
89
120
|
}
|
|
90
|
-
|
|
121
|
+
registerClientHandlers(client, generation) {
|
|
91
122
|
// Client/shard errors (WebSocket hiccups, gateway resumes, etc.). discord.js
|
|
92
123
|
// auto-reconnects; we just need a listener so Node's EventEmitter doesn't
|
|
93
124
|
// rethrow on "error" — without one it surfaces as an uncaughtException and
|
|
94
125
|
// the process-level handler tears down the whole fleet (the built-in adapter
|
|
95
126
|
// shares the fleet process).
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
client.on("error", (err) => {
|
|
128
|
+
if (!this.isCurrentClient(client, generation))
|
|
129
|
+
return;
|
|
130
|
+
console.warn(`[discord:${this.id}] client error: ${err?.message ?? err}`);
|
|
131
|
+
this.healthStatus = "retrying";
|
|
132
|
+
this.emitHealthChanged();
|
|
133
|
+
});
|
|
134
|
+
client.on("shardError", (err, shardId) => {
|
|
135
|
+
if (!this.isCurrentClient(client, generation))
|
|
136
|
+
return;
|
|
137
|
+
console.warn(`[discord:${this.id}] shard ${shardId} error: ${err?.message ?? err}`);
|
|
138
|
+
this.healthStatus = "retrying";
|
|
139
|
+
this.emitHealthChanged();
|
|
140
|
+
});
|
|
141
|
+
client.on("shardReconnecting", (shardId) => {
|
|
142
|
+
if (!this.isCurrentClient(client, generation))
|
|
143
|
+
return;
|
|
144
|
+
this.healthStatus = "retrying";
|
|
145
|
+
console.warn(`[discord:${this.id}] shard ${shardId} reconnecting`);
|
|
146
|
+
});
|
|
147
|
+
client.on("shardResume", (shardId, replayedEvents) => {
|
|
148
|
+
if (!this.isCurrentClient(client, generation))
|
|
149
|
+
return;
|
|
150
|
+
this.healthStatus = "connected";
|
|
151
|
+
this.shardNonReadySince.delete(shardId);
|
|
152
|
+
console.info(`[discord:${this.id}] shard ${shardId} resumed (${replayedEvents} replayed event(s))`);
|
|
153
|
+
});
|
|
154
|
+
client.on("shardReady", (shardId) => {
|
|
155
|
+
if (!this.isCurrentClient(client, generation))
|
|
156
|
+
return;
|
|
157
|
+
this.healthStatus = "connected";
|
|
158
|
+
this.shardNonReadySince.delete(shardId);
|
|
159
|
+
});
|
|
160
|
+
client.on("shardDisconnect", (event, shardId) => {
|
|
161
|
+
if (!this.isCurrentClient(client, generation))
|
|
162
|
+
return;
|
|
163
|
+
this.healthStatus = "retrying";
|
|
164
|
+
this.shardNonReadySince.set(shardId, this.now());
|
|
165
|
+
console.warn(`[discord:${this.id}] shard ${shardId} disconnected (${event.code}: ${event.reason || "no reason"})`);
|
|
166
|
+
});
|
|
167
|
+
client.on("invalidated", () => {
|
|
168
|
+
if (!this.isCurrentClient(client, generation))
|
|
169
|
+
return;
|
|
170
|
+
console.warn(`[discord:${this.id}] gateway session invalidated; rebuilding client`);
|
|
171
|
+
void this.reconnectGateway("gateway session invalidated").catch(() => { });
|
|
172
|
+
});
|
|
173
|
+
client.on("raw", (packet) => {
|
|
174
|
+
if (!this.isCurrentClient(client, generation))
|
|
175
|
+
return;
|
|
176
|
+
if (packet.t)
|
|
177
|
+
this.lastDispatchAt = this.now();
|
|
178
|
+
});
|
|
179
|
+
client.once("ready", () => void this.handleClientReady(client, generation));
|
|
98
180
|
// Reactions on the bot's messages, as inbound events (#408). Both add and remove
|
|
99
181
|
// are reported so an agent can see an approval being withdrawn.
|
|
100
182
|
const onReaction = async (reaction, user, action) => {
|
|
@@ -109,7 +191,9 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
109
191
|
return;
|
|
110
192
|
}
|
|
111
193
|
}
|
|
112
|
-
if (
|
|
194
|
+
if (!this.isCurrentClient(client, generation))
|
|
195
|
+
return;
|
|
196
|
+
if (user.id === client.user?.id)
|
|
113
197
|
return; // our own reaction
|
|
114
198
|
// Other bots' reactions are DELIVERED on purpose: agents react to each
|
|
115
199
|
// other's messages as signals (agent A 👍 → agent B sees it). The noise
|
|
@@ -119,11 +203,11 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
119
203
|
const message = reaction.message;
|
|
120
204
|
// Only reactions on OUR messages are meaningful as agent signals; a user
|
|
121
205
|
// reacting to another user's message is chatter.
|
|
122
|
-
if (message.author?.id && message.author.id !==
|
|
206
|
+
if (message.author?.id && message.author.id !== client.user?.id)
|
|
123
207
|
return;
|
|
124
208
|
if (message.guildId && message.guildId !== this.guildId && !this.openChannels.has(message.channelId))
|
|
125
209
|
return;
|
|
126
|
-
this.
|
|
210
|
+
this.emitFromClient(client, generation, "reaction", {
|
|
127
211
|
source: "discord",
|
|
128
212
|
adapterId: this.id,
|
|
129
213
|
chatId: this.guildId,
|
|
@@ -142,11 +226,13 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
142
226
|
console.warn(`[discord] reaction ${action} handler error (${err.message})`);
|
|
143
227
|
}
|
|
144
228
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
229
|
+
client.on("messageReactionAdd", (reaction, user) => void onReaction(reaction, user, "add"));
|
|
230
|
+
client.on("messageReactionRemove", (reaction, user) => void onReaction(reaction, user, "remove"));
|
|
231
|
+
client.on("messageCreate", async (msg) => {
|
|
148
232
|
try {
|
|
149
|
-
if (
|
|
233
|
+
if (!this.isCurrentClient(client, generation))
|
|
234
|
+
return;
|
|
235
|
+
if (msg.author.id === client.user?.id)
|
|
150
236
|
return; // Ignore own messages
|
|
151
237
|
if (!msg.guildId)
|
|
152
238
|
return;
|
|
@@ -265,6 +351,33 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
265
351
|
size: att.size,
|
|
266
352
|
filename: att.name ?? undefined,
|
|
267
353
|
}));
|
|
354
|
+
// A Discord reply carries only the referenced message id on the new
|
|
355
|
+
// message. Fetch the original so image attachments are delivered with
|
|
356
|
+
// the reply rather than reducing the reference to text alone. Keep this
|
|
357
|
+
// limited to real attachments: ordinary URL auto-embeds must not become
|
|
358
|
+
// downloads (the same policy as the outer message above).
|
|
359
|
+
let replyToText;
|
|
360
|
+
if (msg.reference?.messageId) {
|
|
361
|
+
try {
|
|
362
|
+
const ref = await msg.fetchReference();
|
|
363
|
+
replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
|
|
364
|
+
for (const att of ref.attachments?.values() ?? []) {
|
|
365
|
+
if (!att.contentType?.startsWith("image/"))
|
|
366
|
+
continue;
|
|
367
|
+
if (!attachments.some(existing => existing.fileId === att.id)) {
|
|
368
|
+
attachments.push({
|
|
369
|
+
kind: "photo",
|
|
370
|
+
fileId: att.id,
|
|
371
|
+
mime: att.contentType,
|
|
372
|
+
size: att.size,
|
|
373
|
+
filename: att.name ?? undefined,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
this.attachmentUrls.set(att.id, att.url);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch { /* deleted message or no permission */ }
|
|
380
|
+
}
|
|
268
381
|
// Embed images become synthetic photo attachments so the normal download
|
|
269
382
|
// path (attachment-handler → downloadAttachment → image_path) serves them
|
|
270
383
|
// without knowing where the bytes came from. Their synthetic ids are
|
|
@@ -293,15 +406,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
293
406
|
else
|
|
294
407
|
break;
|
|
295
408
|
}
|
|
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", {
|
|
409
|
+
this.emitFromClient(client, generation, "message", {
|
|
305
410
|
source: "discord",
|
|
306
411
|
adapterId: this.id,
|
|
307
412
|
chatId,
|
|
@@ -326,8 +431,10 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
326
431
|
// Handle button interactions and slash commands
|
|
327
432
|
// Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
|
|
328
433
|
// interaction expires (>3s). Catch to prevent crashing the entire daemon.
|
|
329
|
-
|
|
434
|
+
client.on("interactionCreate", async (interaction) => {
|
|
330
435
|
try {
|
|
436
|
+
if (!this.isCurrentClient(client, generation))
|
|
437
|
+
return;
|
|
331
438
|
// Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
|
|
332
439
|
// A button has a 3s ack window; any early return (unknown guild/channel)
|
|
333
440
|
// or a downstream no-op (e.g. the cancel button was already cleared) would
|
|
@@ -344,7 +451,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
344
451
|
// console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
|
|
345
452
|
return;
|
|
346
453
|
}
|
|
347
|
-
this.
|
|
454
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
348
455
|
callbackData: interaction.customId,
|
|
349
456
|
chatId: this.guildId,
|
|
350
457
|
threadId: interaction.channelId,
|
|
@@ -372,7 +479,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
372
479
|
const callbackData = interaction.values[0];
|
|
373
480
|
if (!callbackData)
|
|
374
481
|
return;
|
|
375
|
-
this.
|
|
482
|
+
this.emitFromClient(client, generation, "callback_query", {
|
|
376
483
|
callbackData,
|
|
377
484
|
chatId: this.guildId,
|
|
378
485
|
threadId: interaction.channelId,
|
|
@@ -392,7 +499,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
392
499
|
if (interaction.commandName === "chat") {
|
|
393
500
|
const text = interaction.options.getString("message") ?? "";
|
|
394
501
|
await interaction.deferReply();
|
|
395
|
-
this.
|
|
502
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
396
503
|
command: "chat",
|
|
397
504
|
channelId: interaction.channelId,
|
|
398
505
|
channelName,
|
|
@@ -419,7 +526,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
419
526
|
for (const opt of interaction.options.data) {
|
|
420
527
|
options[opt.name] = opt.value;
|
|
421
528
|
}
|
|
422
|
-
this.
|
|
529
|
+
this.emitFromClient(client, generation, "slash_command", {
|
|
423
530
|
command: interaction.commandName,
|
|
424
531
|
channelId: interaction.channelId,
|
|
425
532
|
channelName,
|
|
@@ -433,6 +540,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
433
540
|
catch {
|
|
434
541
|
return undefined;
|
|
435
542
|
} },
|
|
543
|
+
dismissResponse: async () => {
|
|
544
|
+
try {
|
|
545
|
+
await interaction.deleteReply();
|
|
546
|
+
}
|
|
547
|
+
catch { /* interaction may already be gone */ }
|
|
548
|
+
},
|
|
436
549
|
respondChoices: async (text, choices) => {
|
|
437
550
|
const select = new StringSelectMenuBuilder()
|
|
438
551
|
.setCustomId(CLASSIC_START_BACKEND_SELECT_ID)
|
|
@@ -459,14 +572,18 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
459
572
|
}
|
|
460
573
|
});
|
|
461
574
|
// Handle channel deletion (equivalent to topic_closed)
|
|
462
|
-
|
|
463
|
-
this.
|
|
575
|
+
client.on("guildCreate", (guild) => {
|
|
576
|
+
if (!this.isCurrentClient(client, generation))
|
|
577
|
+
return;
|
|
578
|
+
this.emitFromClient(client, generation, "new_group_detected", {
|
|
464
579
|
groupId: guild.id,
|
|
465
580
|
groupTitle: guild.name,
|
|
466
581
|
source: "discord",
|
|
467
582
|
});
|
|
468
583
|
});
|
|
469
|
-
|
|
584
|
+
client.on("channelDelete", (channel) => {
|
|
585
|
+
if (!this.isCurrentClient(client, generation))
|
|
586
|
+
return;
|
|
470
587
|
if (!("guildId" in channel))
|
|
471
588
|
return;
|
|
472
589
|
if (channel.guildId !== this.guildId) {
|
|
@@ -474,7 +591,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
474
591
|
return;
|
|
475
592
|
// Allowed: an open classic channel in a non-primary guild was deleted.
|
|
476
593
|
}
|
|
477
|
-
this.
|
|
594
|
+
this.emitFromClient(client, generation, "topic_closed", {
|
|
478
595
|
chatId: this.guildId,
|
|
479
596
|
threadId: channel.id,
|
|
480
597
|
});
|
|
@@ -486,19 +603,252 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
486
603
|
// console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
|
|
487
604
|
}
|
|
488
605
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
606
|
+
isCurrentClient(client, generation) {
|
|
607
|
+
// lifecycleEpoch===0 preserves constructor-level handler tests; a real Client
|
|
608
|
+
// cannot dispatch gateway events before start/login.
|
|
609
|
+
return (this.desiredRunning || this.lifecycleEpoch === 0)
|
|
610
|
+
&& this.client === client
|
|
611
|
+
&& this.clientGeneration === generation;
|
|
612
|
+
}
|
|
613
|
+
emitFromClient(client, generation, event, ...args) {
|
|
614
|
+
return this.isCurrentClient(client, generation) ? this.emit(event, ...args) : false;
|
|
615
|
+
}
|
|
616
|
+
emitHealthChanged() {
|
|
617
|
+
this.emit("gateway_health", this.getHealthSnapshot());
|
|
618
|
+
}
|
|
619
|
+
async loginFreshClient(reason, epoch) {
|
|
620
|
+
const oldClient = this.client;
|
|
621
|
+
// A destroyed discord.js Client cannot be logged in again. Destroy first to
|
|
622
|
+
// avoid overlapping gateway sessions delivering MESSAGE_CREATE twice.
|
|
623
|
+
oldClient.destroy();
|
|
624
|
+
const client = this.clientFactory();
|
|
625
|
+
const generation = ++this.clientGeneration;
|
|
626
|
+
this.client = client;
|
|
627
|
+
this.generationReadyAt = 0;
|
|
628
|
+
this.generationNonReadySince = this.now();
|
|
629
|
+
this.shardNonReadySince.clear();
|
|
630
|
+
this.previousHeartbeatAck.clear();
|
|
631
|
+
this.observedHeartbeatInterval.clear();
|
|
632
|
+
this.registerClientHandlers(client, generation);
|
|
633
|
+
this.emitHealthChanged();
|
|
634
|
+
try {
|
|
635
|
+
await client.login(this.botToken);
|
|
636
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch || this.client !== client) {
|
|
637
|
+
client.destroy();
|
|
638
|
+
throw new Error("Discord gateway login superseded by lifecycle change");
|
|
639
|
+
}
|
|
640
|
+
this.lastReconnectAt = this.now();
|
|
641
|
+
this.lastReconnectReason = reason;
|
|
642
|
+
this.healthStatus = client.isReady() ? "connected" : "starting";
|
|
643
|
+
this.emitHealthChanged();
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
client.destroy();
|
|
647
|
+
throw err;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/** Rebuild just this bot's gateway Client. Calls coalesce into one generation. */
|
|
651
|
+
reconnectGateway(reason) {
|
|
652
|
+
if (this.reconnectPromise)
|
|
653
|
+
return this.reconnectPromise;
|
|
654
|
+
if (!this.desiredRunning)
|
|
655
|
+
return Promise.reject(new Error("Discord adapter is stopped"));
|
|
656
|
+
const epoch = this.lifecycleEpoch;
|
|
657
|
+
const reconnect = (async () => {
|
|
658
|
+
// Yield once so reconnectPromise is installed before destroy/login can emit
|
|
659
|
+
// a synchronous gateway error and re-enter this method.
|
|
660
|
+
await Promise.resolve();
|
|
661
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch) {
|
|
662
|
+
throw new Error("Discord gateway reconnect cancelled by adapter stop");
|
|
663
|
+
}
|
|
664
|
+
this.healthStatus = "retrying";
|
|
665
|
+
this.lastReconnectReason = reason;
|
|
666
|
+
this.reconnectCount++;
|
|
667
|
+
this.emitHealthChanged();
|
|
668
|
+
for (let attempt = 0; this.desiredRunning && epoch === this.lifecycleEpoch; attempt++) {
|
|
669
|
+
if (attempt > 0) {
|
|
670
|
+
const delay = Math.min(this.reconnectBaseDelayMs * 2 ** Math.min(attempt - 1, 6), GATEWAY_MAX_BACKOFF_MS);
|
|
671
|
+
await new Promise(resolve => {
|
|
672
|
+
const timer = setTimeout(resolve, delay);
|
|
673
|
+
timer.unref?.();
|
|
674
|
+
});
|
|
675
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
await this.loginFreshClient(reason, epoch);
|
|
680
|
+
if (this.desiredRunning && epoch === this.lifecycleEpoch) {
|
|
681
|
+
this.healthStatus = "connected";
|
|
682
|
+
this.emitHealthChanged();
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
|
|
688
|
+
break;
|
|
689
|
+
this.healthStatus = "retrying";
|
|
690
|
+
console.warn(`[discord:${this.id}] gateway rebuild attempt ${attempt + 1} failed: ${err.message}`);
|
|
691
|
+
this.emitHealthChanged();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
throw new Error("Discord gateway reconnect cancelled by adapter stop");
|
|
695
|
+
})().finally(() => {
|
|
696
|
+
if (this.reconnectPromise === reconnect)
|
|
697
|
+
this.reconnectPromise = null;
|
|
698
|
+
});
|
|
699
|
+
this.reconnectPromise = reconnect;
|
|
700
|
+
return reconnect;
|
|
701
|
+
}
|
|
702
|
+
async readyClient() {
|
|
703
|
+
// A few focused unit tests install a minimal REST-only client stand-in. Real
|
|
704
|
+
// discord.js Clients always expose isReady().
|
|
705
|
+
const isReady = typeof this.client.isReady === "function"
|
|
706
|
+
? this.client.isReady()
|
|
707
|
+
: true;
|
|
708
|
+
if (isReady && !this.reconnectPromise)
|
|
709
|
+
return this.client;
|
|
710
|
+
const reconnect = this.reconnectPromise ?? this.reconnectGateway("outbound requested while gateway unavailable");
|
|
711
|
+
let timer;
|
|
712
|
+
try {
|
|
713
|
+
await Promise.race([
|
|
714
|
+
reconnect,
|
|
715
|
+
new Promise((_, reject) => {
|
|
716
|
+
timer = setTimeout(() => reject(new Error("Discord gateway reconnect timed out")), GATEWAY_RECONNECT_WAIT_MS);
|
|
717
|
+
timer.unref?.();
|
|
718
|
+
}),
|
|
719
|
+
]);
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
if (timer)
|
|
723
|
+
clearTimeout(timer);
|
|
724
|
+
}
|
|
725
|
+
if (!this.client.isReady())
|
|
726
|
+
throw new Error("Discord gateway is not ready");
|
|
727
|
+
return this.client;
|
|
728
|
+
}
|
|
729
|
+
startGatewayWatchdog() {
|
|
730
|
+
if (this.watchdogTimer)
|
|
731
|
+
clearInterval(this.watchdogTimer);
|
|
732
|
+
this.lastWatchdogTickAt = this.now();
|
|
733
|
+
this.watchdogTimer = setInterval(() => this.checkGatewayLiveness(), this.watchdogIntervalMs);
|
|
734
|
+
this.watchdogTimer.unref?.();
|
|
735
|
+
}
|
|
736
|
+
checkGatewayLiveness() {
|
|
737
|
+
if (!this.desiredRunning || this.reconnectPromise)
|
|
738
|
+
return;
|
|
739
|
+
const now = this.now();
|
|
740
|
+
const elapsed = now - this.lastWatchdogTickAt;
|
|
741
|
+
this.lastWatchdogTickAt = now;
|
|
742
|
+
// Host suspend / an overloaded event loop makes wall-clock ACK age jump. Give
|
|
743
|
+
// discord.js a fresh heartbeat window instead of rebuilding immediately.
|
|
744
|
+
if (elapsed > this.watchdogIntervalMs * 3) {
|
|
745
|
+
this.generationReadyAt = now;
|
|
746
|
+
this.generationNonReadySince = now;
|
|
747
|
+
this.shardNonReadySince.clear();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const shards = [...this.client.ws.shards.values()];
|
|
751
|
+
let staleReason = null;
|
|
752
|
+
if (!this.client.isReady() && this.generationNonReadySince === 0)
|
|
753
|
+
this.generationNonReadySince = now;
|
|
754
|
+
if (this.client.isReady())
|
|
755
|
+
this.generationNonReadySince = 0;
|
|
756
|
+
if (!this.client.isReady() && now - this.generationNonReadySince > this.staleThresholdMs) {
|
|
757
|
+
staleReason = `client non-ready for ${now - this.generationNonReadySince}ms`;
|
|
758
|
+
}
|
|
759
|
+
for (const shard of shards) {
|
|
760
|
+
const lastAck = shard.lastPingTimestamp;
|
|
761
|
+
const previous = this.previousHeartbeatAck.get(shard.id);
|
|
762
|
+
if (lastAck >= 0 && previous != null && lastAck > previous) {
|
|
763
|
+
const interval = lastAck - previous;
|
|
764
|
+
if (interval >= 5_000 && interval <= 120_000)
|
|
765
|
+
this.observedHeartbeatInterval.set(shard.id, interval);
|
|
766
|
+
}
|
|
767
|
+
if (lastAck >= 0)
|
|
768
|
+
this.previousHeartbeatAck.set(shard.id, lastAck);
|
|
769
|
+
const threshold = Math.max(this.staleThresholdMs, 3 * (this.observedHeartbeatInterval.get(shard.id) ?? 0));
|
|
770
|
+
if (shard.status === Status.Ready) {
|
|
771
|
+
this.shardNonReadySince.delete(shard.id);
|
|
772
|
+
if (lastAck === -1) {
|
|
773
|
+
if (this.generationReadyAt > 0 && now - this.generationReadyAt > threshold) {
|
|
774
|
+
staleReason = `shard ${shard.id} has no heartbeat ACK after startup grace`;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
else if (now - lastAck > threshold) {
|
|
778
|
+
staleReason = `shard ${shard.id} heartbeat ACK stale by ${now - lastAck}ms`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
const since = this.shardNonReadySince.get(shard.id) ?? now;
|
|
783
|
+
this.shardNonReadySince.set(shard.id, since);
|
|
784
|
+
if (now - since > threshold)
|
|
785
|
+
staleReason = `shard ${shard.id} status ${shard.status} for ${now - since}ms`;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (staleReason) {
|
|
789
|
+
this.healthStatus = "stale";
|
|
790
|
+
this.emitHealthChanged();
|
|
791
|
+
console.warn(`[discord:${this.id}] gateway watchdog detected ${staleReason}`);
|
|
792
|
+
void this.reconnectGateway(`watchdog: ${staleReason}`).catch(() => { });
|
|
793
|
+
}
|
|
794
|
+
else if (this.client.isReady()
|
|
795
|
+
&& shards.every(shard => shard.status === Status.Ready)
|
|
796
|
+
&& this.healthStatus !== "connected") {
|
|
797
|
+
// discord.js owns the first recovery layer (RESUME + replay). Clear a
|
|
798
|
+
// transient error marker once ACK/status prove that native recovery won.
|
|
799
|
+
this.healthStatus = "connected";
|
|
800
|
+
this.emitHealthChanged();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
getHealthSnapshot() {
|
|
804
|
+
const now = this.now();
|
|
805
|
+
const shards = [...this.client.ws.shards.values()].map(shard => {
|
|
806
|
+
const lastAck = shard.lastPingTimestamp >= 0 ? shard.lastPingTimestamp : null;
|
|
807
|
+
return {
|
|
808
|
+
id: shard.id,
|
|
809
|
+
status: shard.status,
|
|
810
|
+
lastHeartbeatAckAt: lastAck,
|
|
811
|
+
heartbeatAgeMs: lastAck == null ? null : Math.max(0, now - lastAck),
|
|
812
|
+
};
|
|
813
|
+
});
|
|
814
|
+
const heartbeatAcks = shards
|
|
815
|
+
.map(shard => shard.lastHeartbeatAckAt)
|
|
816
|
+
.filter((value) => value != null);
|
|
817
|
+
const lastHeartbeatAckAt = heartbeatAcks.length > 0 ? Math.min(...heartbeatAcks) : null;
|
|
818
|
+
return {
|
|
819
|
+
id: this.id,
|
|
820
|
+
type: this.type,
|
|
821
|
+
status: this.healthStatus,
|
|
822
|
+
generation: this.clientGeneration,
|
|
823
|
+
isReady: this.client.isReady(),
|
|
824
|
+
wsStatus: this.client.ws.status ?? null,
|
|
825
|
+
lastHeartbeatAckAt,
|
|
826
|
+
heartbeatAgeMs: lastHeartbeatAckAt == null ? null : Math.max(0, now - lastHeartbeatAckAt),
|
|
827
|
+
shards,
|
|
828
|
+
lastDispatchAt: this.lastDispatchAt,
|
|
829
|
+
lastReconnectAt: this.lastReconnectAt,
|
|
830
|
+
lastReconnectReason: this.lastReconnectReason,
|
|
831
|
+
reconnectCount: this.reconnectCount,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
async handleClientReady(client, generation) {
|
|
835
|
+
if (!this.isCurrentClient(client, generation))
|
|
836
|
+
return;
|
|
837
|
+
this.healthStatus = "connected";
|
|
838
|
+
this.generationReadyAt = this.now();
|
|
839
|
+
this.generationNonReadySince = 0;
|
|
840
|
+
this.shardNonReadySince.clear();
|
|
841
|
+
try {
|
|
492
842
|
// Register classic bot slash commands (skipped for a secondary bot sharing
|
|
493
843
|
// a guild with the primary — only the primary owns the guild's commands).
|
|
494
844
|
if (this.registerCommands)
|
|
495
845
|
try {
|
|
496
|
-
await
|
|
846
|
+
await client.application?.commands.set([
|
|
497
847
|
{
|
|
498
848
|
name: "start", description: t("slash.start"),
|
|
499
849
|
options: [{
|
|
500
850
|
name: "backend",
|
|
501
|
-
description: "
|
|
851
|
+
description: t("slash.option.backend"),
|
|
502
852
|
type: ApplicationCommandOptionType.String,
|
|
503
853
|
required: true,
|
|
504
854
|
choices: DISCORD_START_BACKEND_CHOICES,
|
|
@@ -506,16 +856,16 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
506
856
|
},
|
|
507
857
|
{ name: "stop", description: t("slash.stop") },
|
|
508
858
|
{
|
|
509
|
-
name: "pause", description: "🔒
|
|
510
|
-
options: [{ name: "instance", description: "
|
|
859
|
+
name: "pause", description: "🔒 " + t("slash.pause"),
|
|
860
|
+
options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
|
|
511
861
|
},
|
|
512
862
|
{
|
|
513
|
-
name: "wake", description: "🔒
|
|
514
|
-
options: [{ name: "instance", description: "
|
|
863
|
+
name: "wake", description: "🔒 " + t("slash.wake"),
|
|
864
|
+
options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
|
|
515
865
|
},
|
|
516
866
|
{
|
|
517
867
|
name: "chat", description: t("slash.chat"),
|
|
518
|
-
options: [{ name: "message", description: "
|
|
868
|
+
options: [{ name: "message", description: t("slash.option.message"), type: 3, required: true }],
|
|
519
869
|
},
|
|
520
870
|
{ name: "status", description: "🔒 " + t("slash.status") },
|
|
521
871
|
{ name: "sysinfo", description: t("slash.sysinfo") },
|
|
@@ -525,22 +875,77 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
525
875
|
{ name: "update", description: "🔒 " + t("slash.update") },
|
|
526
876
|
{ name: "doctor", description: "🔒 " + t("slash.doctor") },
|
|
527
877
|
{ name: "usage", description: t("slash.usage") },
|
|
878
|
+
{
|
|
879
|
+
name: "tips", description: t("slash.tips"),
|
|
880
|
+
options: [{
|
|
881
|
+
name: "mode", description: t("slash.option.tips_mode"),
|
|
882
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
883
|
+
choices: [
|
|
884
|
+
{ name: "on", value: "on" },
|
|
885
|
+
{ name: "off", value: "off" },
|
|
886
|
+
{ name: "advanced on", value: "advanced on" },
|
|
887
|
+
],
|
|
888
|
+
}],
|
|
889
|
+
},
|
|
528
890
|
{ name: "compact", description: "🔒 " + t("slash.compact") },
|
|
529
891
|
{
|
|
530
892
|
name: "steer", description: t("slash.steer"),
|
|
531
893
|
options: [{
|
|
532
|
-
name: "message", description: "
|
|
894
|
+
name: "message", description: t("slash.option.steer_message"),
|
|
533
895
|
type: ApplicationCommandOptionType.String, required: true,
|
|
534
896
|
}],
|
|
535
897
|
},
|
|
898
|
+
{
|
|
899
|
+
name: "btw", description: t("slash.btw"),
|
|
900
|
+
options: [{
|
|
901
|
+
name: "message", description: t("slash.option.btw_message"),
|
|
902
|
+
type: ApplicationCommandOptionType.String, required: true,
|
|
903
|
+
}],
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
name: "login", description: "🔒 " + t("slash.login"),
|
|
907
|
+
options: [
|
|
908
|
+
{
|
|
909
|
+
name: "backend", description: t("slash.option.login_backend"),
|
|
910
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
911
|
+
choices: [
|
|
912
|
+
{ name: "claude-code", value: "claude-code" },
|
|
913
|
+
{ name: "codex", value: "codex" },
|
|
914
|
+
{ name: "kiro-cli", value: "kiro-cli" },
|
|
915
|
+
{ name: "grok", value: "grok" },
|
|
916
|
+
{ name: "antigravity", value: "antigravity" },
|
|
917
|
+
],
|
|
918
|
+
},
|
|
919
|
+
{ name: "code", description: t("slash.option.login_code"), type: ApplicationCommandOptionType.String, required: false },
|
|
920
|
+
{ name: "cancel", description: t("slash.option.login_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
|
|
921
|
+
],
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
name: "install-cli", description: "🔒 " + t("slash.install_cli"),
|
|
925
|
+
options: [
|
|
926
|
+
{
|
|
927
|
+
name: "backend", description: t("slash.option.install_backend"),
|
|
928
|
+
type: ApplicationCommandOptionType.String, required: false,
|
|
929
|
+
choices: [
|
|
930
|
+
{ name: "claude-code", value: "claude-code" },
|
|
931
|
+
{ name: "codex", value: "codex" },
|
|
932
|
+
{ name: "kiro-cli", value: "kiro-cli" },
|
|
933
|
+
{ name: "grok", value: "grok" },
|
|
934
|
+
{ name: "antigravity", value: "antigravity" },
|
|
935
|
+
{ name: "opencode", value: "opencode" },
|
|
936
|
+
],
|
|
937
|
+
},
|
|
938
|
+
{ name: "cancel", description: t("slash.option.install_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
|
|
939
|
+
],
|
|
940
|
+
},
|
|
536
941
|
{ name: "clear", description: "🔒 " + t("slash.clear") },
|
|
537
942
|
{
|
|
538
|
-
name: "model", description: "🔒
|
|
943
|
+
name: "model", description: "🔒 " + t("slash.model"),
|
|
539
944
|
},
|
|
540
945
|
{
|
|
541
946
|
name: "effort", description: "🔒 " + t("slash.effort"),
|
|
542
947
|
options: [{
|
|
543
|
-
name: "level", description: "
|
|
948
|
+
name: "level", description: t("slash.option.effort_level"),
|
|
544
949
|
type: ApplicationCommandOptionType.String, required: false,
|
|
545
950
|
choices: [
|
|
546
951
|
{ name: "low", value: "low" }, { name: "medium", value: "medium" },
|
|
@@ -553,13 +958,13 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
553
958
|
{
|
|
554
959
|
name: "save", description: "🔒 " + t("slash.save"),
|
|
555
960
|
options: [
|
|
556
|
-
{ name: "filename", description: "
|
|
557
|
-
{ name: "force", description: "
|
|
961
|
+
{ name: "filename", description: t("slash.option.save_filename"), type: 3, required: true },
|
|
962
|
+
{ name: "force", description: t("slash.option.save_force"), type: 5, required: false },
|
|
558
963
|
],
|
|
559
964
|
},
|
|
560
965
|
{
|
|
561
966
|
name: "load", description: "🔒 " + t("slash.load"),
|
|
562
|
-
options: [{ name: "filename", description: "
|
|
967
|
+
options: [{ name: "filename", description: t("slash.option.load_filename"), type: 3, required: true }],
|
|
563
968
|
},
|
|
564
969
|
{ name: "cancel", description: t("slash.cancel") },
|
|
565
970
|
]);
|
|
@@ -567,11 +972,40 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
567
972
|
catch (err) {
|
|
568
973
|
// Non-fatal — slash commands may fail on network issues
|
|
569
974
|
}
|
|
570
|
-
this.
|
|
571
|
-
|
|
572
|
-
|
|
975
|
+
if (!this.isCurrentClient(client, generation))
|
|
976
|
+
return;
|
|
977
|
+
this.emit("started", client.user?.username ?? "discord-bot", client.user?.id);
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
980
|
+
console.warn(`[discord:${this.id}] ready handler failed: ${err.message}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async start() {
|
|
984
|
+
if (this.desiredRunning && this.client.isReady())
|
|
985
|
+
return;
|
|
986
|
+
this.desiredRunning = true;
|
|
987
|
+
const epoch = ++this.lifecycleEpoch;
|
|
988
|
+
this.healthStatus = "starting";
|
|
989
|
+
this.queue.start();
|
|
990
|
+
try {
|
|
991
|
+
await this.loginFreshClient("startup", epoch);
|
|
992
|
+
this.startGatewayWatchdog();
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
if (epoch === this.lifecycleEpoch) {
|
|
996
|
+
this.desiredRunning = false;
|
|
997
|
+
this.healthStatus = "stopped";
|
|
998
|
+
}
|
|
999
|
+
throw err;
|
|
1000
|
+
}
|
|
573
1001
|
}
|
|
574
1002
|
async stop() {
|
|
1003
|
+
this.desiredRunning = false;
|
|
1004
|
+
this.lifecycleEpoch++;
|
|
1005
|
+
this.healthStatus = "stopped";
|
|
1006
|
+
if (this.watchdogTimer)
|
|
1007
|
+
clearInterval(this.watchdogTimer);
|
|
1008
|
+
this.watchdogTimer = null;
|
|
575
1009
|
this.queue.stop();
|
|
576
1010
|
this.client.destroy();
|
|
577
1011
|
}
|
|
@@ -590,7 +1024,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
590
1024
|
const message = await interaction.editReply({ content: reply, components: [] });
|
|
591
1025
|
return message.id;
|
|
592
1026
|
}
|
|
593
|
-
const chunks =
|
|
1027
|
+
const chunks = splitTextFenceAware(reply, EMBED_MAX);
|
|
594
1028
|
const message = await interaction.editReply({ content: "", embeds: [{ description: chunks[0] }], components: [] });
|
|
595
1029
|
for (let i = 1; i < chunks.length; i++) {
|
|
596
1030
|
await interaction.followUp({ ephemeral: true, embeds: [{ description: chunks[i] }] });
|
|
@@ -601,16 +1035,18 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
601
1035
|
const channelId = opts?.threadId ?? chatId;
|
|
602
1036
|
const channel = await this._fetchTextChannel(channelId);
|
|
603
1037
|
const chunkLimit = opts?.chunkLimit ?? DISCORD_MAX_LENGTH;
|
|
604
|
-
const chunks =
|
|
1038
|
+
const chunks = splitTextFenceAware(text, chunkLimit);
|
|
605
1039
|
if (chunks.length === 0)
|
|
606
1040
|
throw new Error("Empty text");
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1041
|
+
// Await every platform POST before reporting success. Previously only the
|
|
1042
|
+
// first chunk was awaited and later chunks were fire-and-forget queue items,
|
|
1043
|
+
// so the reply tool could return success for a silently truncated message.
|
|
1044
|
+
let first;
|
|
1045
|
+
for (const chunk of chunks) {
|
|
1046
|
+
const sent = await channel.send(opts?.disablePreview
|
|
1047
|
+
? { content: chunk, flags: MessageFlags.SuppressEmbeds }
|
|
1048
|
+
: chunk);
|
|
1049
|
+
first ??= sent;
|
|
614
1050
|
}
|
|
615
1051
|
return {
|
|
616
1052
|
messageId: first.id,
|
|
@@ -636,7 +1072,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
636
1072
|
}
|
|
637
1073
|
catch { /* not in that channel — fall through to scan */ }
|
|
638
1074
|
try {
|
|
639
|
-
const guild = await this.
|
|
1075
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
640
1076
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
641
1077
|
for (const [, ch] of channels) {
|
|
642
1078
|
try {
|
|
@@ -660,6 +1096,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
660
1096
|
}
|
|
661
1097
|
/** Edit text and clear components (Discord keeps components on a plain edit,
|
|
662
1098
|
* so we must pass an empty array to drop the Cancel button). */
|
|
1099
|
+
/** Keyboard-only removal — Discord keeps the original content untouched. */
|
|
1100
|
+
async removeMessageButtons(chatId, messageId, threadId) {
|
|
1101
|
+
const channel = await this._fetchTextChannel(threadId ?? chatId);
|
|
1102
|
+
const msg = await channel.messages.fetch(messageId);
|
|
1103
|
+
await msg.edit({ components: [] });
|
|
1104
|
+
}
|
|
663
1105
|
async editMessageRemoveButtons(chatId, messageId, text, threadId) {
|
|
664
1106
|
// Prefer the exact channel (handles forum-topic threads, which a GuildText
|
|
665
1107
|
// scan misses); fall back to scanning top-level text channels.
|
|
@@ -671,7 +1113,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
671
1113
|
}
|
|
672
1114
|
catch { /* not in that channel — fall through to scan */ }
|
|
673
1115
|
try {
|
|
674
|
-
const guild = await this.
|
|
1116
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
675
1117
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
676
1118
|
for (const [, ch] of channels) {
|
|
677
1119
|
try {
|
|
@@ -698,7 +1140,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
698
1140
|
}
|
|
699
1141
|
catch { /* not in that channel — fall through to scan */ }
|
|
700
1142
|
try {
|
|
701
|
-
const guild = await this.
|
|
1143
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
702
1144
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
703
1145
|
for (const [, ch] of channels) {
|
|
704
1146
|
try {
|
|
@@ -721,7 +1163,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
721
1163
|
const encoded = encodeURIComponent(emoji);
|
|
722
1164
|
try {
|
|
723
1165
|
// Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
|
|
724
|
-
await this.
|
|
1166
|
+
await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
|
|
725
1167
|
return;
|
|
726
1168
|
}
|
|
727
1169
|
catch (directError) {
|
|
@@ -889,7 +1331,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
889
1331
|
}
|
|
890
1332
|
// ── Topology: create channel ────────────────────────────────────────────
|
|
891
1333
|
async _resolveCategory() {
|
|
892
|
-
const guild = await this.
|
|
1334
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
893
1335
|
await guild.channels.fetch();
|
|
894
1336
|
const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
|
|
895
1337
|
if (existing)
|
|
@@ -910,7 +1352,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
910
1352
|
return this.categoryIdPromise;
|
|
911
1353
|
}
|
|
912
1354
|
async createTopic(name) {
|
|
913
|
-
const guild = await this.
|
|
1355
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
914
1356
|
const categoryId = await this.ensureCategoryId();
|
|
915
1357
|
try {
|
|
916
1358
|
const channel = await guild.channels.create({
|
|
@@ -936,7 +1378,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
936
1378
|
}
|
|
937
1379
|
}
|
|
938
1380
|
async deleteTopic(topicId) {
|
|
939
|
-
const channel = await this.
|
|
1381
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
940
1382
|
// Only delete GuildText channels created by createTopic — never categories or forums
|
|
941
1383
|
if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
|
|
942
1384
|
await channel.delete();
|
|
@@ -944,7 +1386,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
944
1386
|
}
|
|
945
1387
|
async topicExists(topicId) {
|
|
946
1388
|
try {
|
|
947
|
-
const channel = await this.
|
|
1389
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
948
1390
|
return channel != null;
|
|
949
1391
|
}
|
|
950
1392
|
catch {
|
|
@@ -961,13 +1403,4 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
961
1403
|
}
|
|
962
1404
|
}
|
|
963
1405
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
964
|
-
function splitText(text, limit) {
|
|
965
|
-
const chunks = [];
|
|
966
|
-
let offset = 0;
|
|
967
|
-
while (offset < text.length) {
|
|
968
|
-
chunks.push(text.slice(offset, offset + limit));
|
|
969
|
-
offset += limit;
|
|
970
|
-
}
|
|
971
|
-
return chunks;
|
|
972
|
-
}
|
|
973
1406
|
//# sourceMappingURL=discord.js.map
|