@songsid/agend 2.1.4-beta.6 → 2.1.4-beta.60
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 +143 -7
- 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 +529 -89
- 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 +28 -25
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -4
- 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 +16 -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 +506 -11
- package/dist/daemon.js +2162 -313
- 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 +422 -12
- package/dist/fleet-manager.js +2961 -635
- 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 +4 -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 +172 -8
- 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 +99 -0
- package/dist/instance-lifecycle.js +357 -11
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +74 -3
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +1022 -310
- 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 +11 -3
- package/dist/outbound-schemas.js +11 -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/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 +45 -6
- package/dist/topic-commands.js +394 -66
- 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 +11 -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.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/workflow-templates/default.md +2 -1
- 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,15 +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") },
|
|
891
|
+
{
|
|
892
|
+
name: "steer", description: t("slash.steer"),
|
|
893
|
+
options: [{
|
|
894
|
+
name: "message", description: t("slash.option.steer_message"),
|
|
895
|
+
type: ApplicationCommandOptionType.String, required: true,
|
|
896
|
+
}],
|
|
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
|
+
},
|
|
529
941
|
{ name: "clear", description: "🔒 " + t("slash.clear") },
|
|
530
942
|
{
|
|
531
|
-
name: "model", description: "🔒
|
|
943
|
+
name: "model", description: "🔒 " + t("slash.model"),
|
|
532
944
|
},
|
|
533
945
|
{
|
|
534
946
|
name: "effort", description: "🔒 " + t("slash.effort"),
|
|
535
947
|
options: [{
|
|
536
|
-
name: "level", description: "
|
|
948
|
+
name: "level", description: t("slash.option.effort_level"),
|
|
537
949
|
type: ApplicationCommandOptionType.String, required: false,
|
|
538
950
|
choices: [
|
|
539
951
|
{ name: "low", value: "low" }, { name: "medium", value: "medium" },
|
|
@@ -546,13 +958,13 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
546
958
|
{
|
|
547
959
|
name: "save", description: "🔒 " + t("slash.save"),
|
|
548
960
|
options: [
|
|
549
|
-
{ name: "filename", description: "
|
|
550
|
-
{ 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 },
|
|
551
963
|
],
|
|
552
964
|
},
|
|
553
965
|
{
|
|
554
966
|
name: "load", description: "🔒 " + t("slash.load"),
|
|
555
|
-
options: [{ name: "filename", description: "
|
|
967
|
+
options: [{ name: "filename", description: t("slash.option.load_filename"), type: 3, required: true }],
|
|
556
968
|
},
|
|
557
969
|
{ name: "cancel", description: t("slash.cancel") },
|
|
558
970
|
]);
|
|
@@ -560,11 +972,40 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
560
972
|
catch (err) {
|
|
561
973
|
// Non-fatal — slash commands may fail on network issues
|
|
562
974
|
}
|
|
563
|
-
this.
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|
|
566
1001
|
}
|
|
567
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;
|
|
568
1009
|
this.queue.stop();
|
|
569
1010
|
this.client.destroy();
|
|
570
1011
|
}
|
|
@@ -583,7 +1024,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
583
1024
|
const message = await interaction.editReply({ content: reply, components: [] });
|
|
584
1025
|
return message.id;
|
|
585
1026
|
}
|
|
586
|
-
const chunks =
|
|
1027
|
+
const chunks = splitTextFenceAware(reply, EMBED_MAX);
|
|
587
1028
|
const message = await interaction.editReply({ content: "", embeds: [{ description: chunks[0] }], components: [] });
|
|
588
1029
|
for (let i = 1; i < chunks.length; i++) {
|
|
589
1030
|
await interaction.followUp({ ephemeral: true, embeds: [{ description: chunks[i] }] });
|
|
@@ -594,16 +1035,18 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
594
1035
|
const channelId = opts?.threadId ?? chatId;
|
|
595
1036
|
const channel = await this._fetchTextChannel(channelId);
|
|
596
1037
|
const chunkLimit = opts?.chunkLimit ?? DISCORD_MAX_LENGTH;
|
|
597
|
-
const chunks =
|
|
1038
|
+
const chunks = splitTextFenceAware(text, chunkLimit);
|
|
598
1039
|
if (chunks.length === 0)
|
|
599
1040
|
throw new Error("Empty text");
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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;
|
|
607
1050
|
}
|
|
608
1051
|
return {
|
|
609
1052
|
messageId: first.id,
|
|
@@ -629,7 +1072,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
629
1072
|
}
|
|
630
1073
|
catch { /* not in that channel — fall through to scan */ }
|
|
631
1074
|
try {
|
|
632
|
-
const guild = await this.
|
|
1075
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
633
1076
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
634
1077
|
for (const [, ch] of channels) {
|
|
635
1078
|
try {
|
|
@@ -653,6 +1096,12 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
653
1096
|
}
|
|
654
1097
|
/** Edit text and clear components (Discord keeps components on a plain edit,
|
|
655
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
|
+
}
|
|
656
1105
|
async editMessageRemoveButtons(chatId, messageId, text, threadId) {
|
|
657
1106
|
// Prefer the exact channel (handles forum-topic threads, which a GuildText
|
|
658
1107
|
// scan misses); fall back to scanning top-level text channels.
|
|
@@ -664,7 +1113,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
664
1113
|
}
|
|
665
1114
|
catch { /* not in that channel — fall through to scan */ }
|
|
666
1115
|
try {
|
|
667
|
-
const guild = await this.
|
|
1116
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
668
1117
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
669
1118
|
for (const [, ch] of channels) {
|
|
670
1119
|
try {
|
|
@@ -691,7 +1140,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
691
1140
|
}
|
|
692
1141
|
catch { /* not in that channel — fall through to scan */ }
|
|
693
1142
|
try {
|
|
694
|
-
const guild = await this.
|
|
1143
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
695
1144
|
const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
|
|
696
1145
|
for (const [, ch] of channels) {
|
|
697
1146
|
try {
|
|
@@ -714,7 +1163,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
714
1163
|
const encoded = encodeURIComponent(emoji);
|
|
715
1164
|
try {
|
|
716
1165
|
// Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
|
|
717
|
-
await this.
|
|
1166
|
+
await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
|
|
718
1167
|
return;
|
|
719
1168
|
}
|
|
720
1169
|
catch (directError) {
|
|
@@ -882,7 +1331,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
882
1331
|
}
|
|
883
1332
|
// ── Topology: create channel ────────────────────────────────────────────
|
|
884
1333
|
async _resolveCategory() {
|
|
885
|
-
const guild = await this.
|
|
1334
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
886
1335
|
await guild.channels.fetch();
|
|
887
1336
|
const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
|
|
888
1337
|
if (existing)
|
|
@@ -903,7 +1352,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
903
1352
|
return this.categoryIdPromise;
|
|
904
1353
|
}
|
|
905
1354
|
async createTopic(name) {
|
|
906
|
-
const guild = await this.
|
|
1355
|
+
const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
|
|
907
1356
|
const categoryId = await this.ensureCategoryId();
|
|
908
1357
|
try {
|
|
909
1358
|
const channel = await guild.channels.create({
|
|
@@ -929,7 +1378,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
929
1378
|
}
|
|
930
1379
|
}
|
|
931
1380
|
async deleteTopic(topicId) {
|
|
932
|
-
const channel = await this.
|
|
1381
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
933
1382
|
// Only delete GuildText channels created by createTopic — never categories or forums
|
|
934
1383
|
if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
|
|
935
1384
|
await channel.delete();
|
|
@@ -937,7 +1386,7 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
937
1386
|
}
|
|
938
1387
|
async topicExists(topicId) {
|
|
939
1388
|
try {
|
|
940
|
-
const channel = await this.
|
|
1389
|
+
const channel = await (await this.readyClient()).channels.fetch(String(topicId));
|
|
941
1390
|
return channel != null;
|
|
942
1391
|
}
|
|
943
1392
|
catch {
|
|
@@ -954,13 +1403,4 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
954
1403
|
}
|
|
955
1404
|
}
|
|
956
1405
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
957
|
-
function splitText(text, limit) {
|
|
958
|
-
const chunks = [];
|
|
959
|
-
let offset = 0;
|
|
960
|
-
while (offset < text.length) {
|
|
961
|
-
chunks.push(text.slice(offset, offset + limit));
|
|
962
|
-
offset += limit;
|
|
963
|
-
}
|
|
964
|
-
return chunks;
|
|
965
|
-
}
|
|
966
1406
|
//# sourceMappingURL=discord.js.map
|