@songsid/agend 2.0.8-beta.1 → 2.0.8-beta.3

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 CHANGED
@@ -81,7 +81,7 @@ agend fleet start # 3. Launch your fleet 🎉
81
81
 
82
82
  Open Telegram, send a message to your bot, and start coding from your phone.
83
83
 
84
- > **Discord?** `agend quickstart` supports Discord too — install the plugin first: `npm install -g @songsid/agend-plugin-discord`. See [Discord setup guide](docs/features.md#discord-adapter-mvp).
84
+ > **Discord?** `agend quickstart` supports Discord too — it's built in, no extra install needed. See [Discord setup guide](docs/features.md#discord-adapter-mvp).
85
85
 
86
86
  ## How It Works
87
87
 
@@ -148,13 +148,10 @@ ClassicBot lets users start AI agents in any Discord text channel using slash co
148
148
  ### Setup
149
149
 
150
150
  ```bash
151
- # 1. Install Discord plugin
152
- npm install -g @songsid/agend-plugin-discord
153
-
154
- # 2. Run quickstart (select Discord)
151
+ # 1. Run quickstart (select Discord — built in, no extra install)
155
152
  agend quickstart
156
153
 
157
- # 3. Start the fleet
154
+ # 2. Start the fleet
158
155
  agend fleet start
159
156
  ```
160
157
 
@@ -0,0 +1,53 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { ChannelAdapter, ApprovalHandle, SendOpts, SentMessage, PermissionPrompt, Choice, AlertData } from "../types.js";
3
+ import type { AccessManager } from "../access-manager.js";
4
+ export interface DiscordAdapterOptions {
5
+ id: string;
6
+ botToken: string;
7
+ accessManager: AccessManager;
8
+ inboxDir: string;
9
+ guildId: string;
10
+ categoryName?: string;
11
+ generalChannelId?: string;
12
+ }
13
+ export declare class DiscordAdapter extends EventEmitter implements ChannelAdapter {
14
+ readonly type = "discord";
15
+ readonly topology: "channels";
16
+ readonly id: string;
17
+ private client;
18
+ private botToken;
19
+ private accessManager;
20
+ private inboxDir;
21
+ private guildId;
22
+ private openChannels;
23
+ private categoryName;
24
+ private generalChannelId?;
25
+ private queue;
26
+ private lastChatId;
27
+ private attachmentUrls;
28
+ private categoryIdPromise?;
29
+ constructor(opts: DiscordAdapterOptions);
30
+ private _fetchTextChannel;
31
+ private _registerHandlers;
32
+ /** Mark channels as open (skip access control) — used for classic bot channels */
33
+ setOpenChannels(channelIds: string[]): void;
34
+ start(): Promise<void>;
35
+ stop(): Promise<void>;
36
+ sendText(chatId: string, text: string, opts?: SendOpts): Promise<SentMessage>;
37
+ sendFile(chatId: string, filePath: string, opts?: SendOpts): Promise<SentMessage>;
38
+ editMessage(chatId: string, messageId: string, text: string): Promise<void>;
39
+ react(chatId: string, messageId: string, emoji: string): Promise<void>;
40
+ sendApproval(prompt: PermissionPrompt, callback: (decision: "approve" | "approve_always" | "deny") => void, signal?: AbortSignal, threadId?: string): Promise<ApprovalHandle>;
41
+ getChatId(): string | null;
42
+ setChatId(chatId: string): void;
43
+ downloadAttachment(fileId: string): Promise<string>;
44
+ promptUser(chatId: string, text: string, choices: Choice[], opts?: SendOpts): Promise<string>;
45
+ notifyAlert(chatId: string, alert: AlertData, opts?: SendOpts): Promise<SentMessage>;
46
+ private _resolveCategory;
47
+ private ensureCategoryId;
48
+ createTopic(name: string): Promise<string>;
49
+ deleteTopic(topicId: number | string): Promise<void>;
50
+ topicExists(topicId: number | string): Promise<boolean>;
51
+ handlePairing(chatId: string, userId: string): Promise<string>;
52
+ confirmPairing(code: string, callerUserId?: string): Promise<boolean>;
53
+ }
@@ -0,0 +1,584 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { Readable } from "node:stream";
7
+ import { createWriteStream } from "node:fs";
8
+ import { Client, GatewayIntentBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, } from "discord.js";
9
+ import { MessageQueue } from "../message-queue.js";
10
+ const DISCORD_MAX_LENGTH = 2000;
11
+ export class DiscordAdapter extends EventEmitter {
12
+ type = "discord";
13
+ topology = "channels";
14
+ id;
15
+ client;
16
+ botToken;
17
+ accessManager;
18
+ inboxDir;
19
+ guildId;
20
+ openChannels = new Set();
21
+ categoryName;
22
+ generalChannelId;
23
+ queue;
24
+ lastChatId = null;
25
+ attachmentUrls = new Map();
26
+ categoryIdPromise;
27
+ constructor(opts) {
28
+ super();
29
+ this.id = opts.id;
30
+ this.botToken = opts.botToken;
31
+ this.accessManager = opts.accessManager;
32
+ this.inboxDir = opts.inboxDir;
33
+ this.guildId = opts.guildId;
34
+ this.categoryName = opts.categoryName ?? "AgEnD Agents";
35
+ this.generalChannelId = opts.generalChannelId;
36
+ mkdirSync(this.inboxDir, { recursive: true });
37
+ this.client = new Client({
38
+ intents: [
39
+ GatewayIntentBits.Guilds,
40
+ GatewayIntentBits.GuildMessages,
41
+ GatewayIntentBits.MessageContent,
42
+ ],
43
+ });
44
+ this.queue = new MessageQueue({
45
+ send: async (chatId, threadId, text) => {
46
+ const channel = await this._fetchTextChannel(threadId ?? chatId);
47
+ const msg = await channel.send(text);
48
+ return { messageId: msg.id };
49
+ },
50
+ edit: async (chatId, messageId, text) => {
51
+ const channel = await this._fetchTextChannel(chatId);
52
+ const msg = await channel.messages.fetch(messageId);
53
+ await msg.edit(text);
54
+ },
55
+ sendFile: async (chatId, threadId, filePath) => {
56
+ const channel = await this._fetchTextChannel(threadId ?? chatId);
57
+ const msg = await channel.send({ files: [filePath] });
58
+ return { messageId: msg.id };
59
+ },
60
+ });
61
+ this._registerHandlers();
62
+ }
63
+ async _fetchTextChannel(channelId) {
64
+ const channel = await this.client.channels.fetch(channelId);
65
+ if (!channel?.isTextBased()) {
66
+ throw new Error(`Channel ${channelId} is not a text channel`);
67
+ }
68
+ return channel;
69
+ }
70
+ _registerHandlers() {
71
+ this.client.on("messageCreate", async (msg) => {
72
+ if (msg.author.id === this.client.user?.id)
73
+ return; // Ignore own messages
74
+ if (!msg.guildId)
75
+ return;
76
+ if (msg.guildId !== this.guildId) {
77
+ if (!this.openChannels.has(msg.channelId))
78
+ return;
79
+ console.log(`[discord] classic channel message from non-primary guild ${msg.guildId} channel ${msg.channelId}`);
80
+ }
81
+ const userId = msg.author.id;
82
+ // Access control moved to fleet-manager to allow classic channels for all users
83
+ const chatId = this.guildId;
84
+ const threadId = msg.channelId;
85
+ const messageId = msg.id;
86
+ const username = msg.author.username;
87
+ let text = msg.content;
88
+ // Handle forwarded messages (messageSnapshots) and embeds
89
+ if (!text) {
90
+ const parts = [];
91
+ // Forwarded message snapshots (Discord forward feature)
92
+ if (msg.messageSnapshots?.size > 0) {
93
+ for (const [, snap] of msg.messageSnapshots) {
94
+ if (snap.message?.content)
95
+ parts.push(snap.message.content);
96
+ if (snap.message?.embeds?.length) {
97
+ for (const e of snap.message.embeds) {
98
+ if (e.title)
99
+ parts.push(e.title);
100
+ if (e.description)
101
+ parts.push(e.description);
102
+ }
103
+ }
104
+ // Forward attachments (images, files) into the main message
105
+ if (snap.message?.attachments?.size > 0) {
106
+ for (const [, att] of snap.message.attachments) {
107
+ msg.attachments.set(att.id, att);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ // Rich embeds (links, bot messages, etc.)
113
+ if (parts.length === 0 && msg.embeds.length > 0) {
114
+ for (const e of msg.embeds) {
115
+ if (e.title)
116
+ parts.push(e.title);
117
+ if (e.description)
118
+ parts.push(e.description);
119
+ if (e.fields?.length) {
120
+ for (const f of e.fields)
121
+ parts.push(`${f.name}: ${f.value}`);
122
+ }
123
+ }
124
+ }
125
+ if (parts.length > 0)
126
+ text = parts.join("\n");
127
+ }
128
+ const isBotMessage = msg.author.bot;
129
+ // Collect attachments
130
+ const attachments = msg.attachments.map((att) => ({
131
+ kind: (att.contentType?.startsWith("image/") ? "photo"
132
+ : att.contentType?.startsWith("video/") ? "video"
133
+ : att.contentType?.startsWith("audio/") ? "audio"
134
+ : "document"),
135
+ fileId: att.id,
136
+ mime: att.contentType ?? undefined,
137
+ size: att.size,
138
+ filename: att.name ?? undefined,
139
+ }));
140
+ // Store attachment URLs for later download
141
+ for (const att of msg.attachments.values()) {
142
+ this.attachmentUrls.set(att.id, att.url);
143
+ }
144
+ while (this.attachmentUrls.size > 1000) {
145
+ const first = this.attachmentUrls.keys().next().value;
146
+ if (first)
147
+ this.attachmentUrls.delete(first);
148
+ else
149
+ break;
150
+ }
151
+ let replyToText;
152
+ if (msg.reference?.messageId) {
153
+ try {
154
+ const ref = await msg.fetchReference();
155
+ replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
156
+ }
157
+ catch { /* deleted message or no permission */ }
158
+ }
159
+ this.emit("message", {
160
+ source: "discord",
161
+ adapterId: this.id,
162
+ chatId,
163
+ threadId,
164
+ messageId,
165
+ userId,
166
+ username,
167
+ text,
168
+ timestamp: msg.createdAt,
169
+ isBotMessage,
170
+ attachments: attachments.length > 0 ? attachments : undefined,
171
+ replyTo: msg.reference?.messageId ?? undefined,
172
+ replyToText,
173
+ });
174
+ });
175
+ // Handle button interactions and slash commands
176
+ // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
177
+ // interaction expires (>3s). Catch to prevent crashing the entire daemon.
178
+ this.client.on("interactionCreate", async (interaction) => {
179
+ try {
180
+ if (interaction.guildId !== this.guildId) {
181
+ // Allow slash commands through — guild whitelist is checked by fleet-manager.
182
+ // Only block non-slash interactions (buttons) from unknown channels.
183
+ if (!interaction.isChatInputCommand() && !this.openChannels.has(interaction.channelId ?? ""))
184
+ return;
185
+ if (!interaction.isChatInputCommand()) {
186
+ console.log(`[discord] classic channel interaction from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
187
+ }
188
+ }
189
+ if (interaction.isButton()) {
190
+ await interaction.deferUpdate();
191
+ this.emit("callback_query", {
192
+ callbackData: interaction.customId,
193
+ chatId: this.guildId,
194
+ threadId: interaction.channelId,
195
+ messageId: interaction.message.id,
196
+ });
197
+ return;
198
+ }
199
+ if (interaction.isChatInputCommand()) {
200
+ const channelName = interaction.channel && "name" in interaction.channel ? (interaction.channel.name ?? "") : "";
201
+ const username = interaction.user.username;
202
+ if (interaction.commandName === "chat") {
203
+ const text = interaction.options.getString("message") ?? "";
204
+ await interaction.deferReply();
205
+ this.emit("slash_command", {
206
+ command: "chat",
207
+ channelId: interaction.channelId,
208
+ channelName,
209
+ guildId: interaction.guildId ?? undefined,
210
+ userId: interaction.user.id,
211
+ username,
212
+ text,
213
+ respond: async (reply) => { try {
214
+ const m = await interaction.editReply(reply);
215
+ return m.id;
216
+ }
217
+ catch {
218
+ return undefined;
219
+ } },
220
+ });
221
+ }
222
+ else {
223
+ await interaction.deferReply({ ephemeral: true });
224
+ // Extract options as key-value pairs for fleet-manager
225
+ const options = {};
226
+ for (const opt of interaction.options.data) {
227
+ options[opt.name] = opt.value;
228
+ }
229
+ this.emit("slash_command", {
230
+ command: interaction.commandName,
231
+ channelId: interaction.channelId,
232
+ channelName,
233
+ guildId: interaction.guildId ?? undefined,
234
+ userId: interaction.user.id,
235
+ username,
236
+ options,
237
+ respond: async (reply) => { try {
238
+ await interaction.editReply(reply);
239
+ }
240
+ catch { /* expired */ } },
241
+ });
242
+ }
243
+ }
244
+ }
245
+ catch (err) {
246
+ console.warn(`[discord] interactionCreate error (${err.message})`);
247
+ }
248
+ });
249
+ // Handle channel deletion (equivalent to topic_closed)
250
+ this.client.on("guildCreate", (guild) => {
251
+ this.emit("new_group_detected", {
252
+ groupId: guild.id,
253
+ groupTitle: guild.name,
254
+ source: "discord",
255
+ });
256
+ });
257
+ this.client.on("channelDelete", (channel) => {
258
+ if (!("guildId" in channel))
259
+ return;
260
+ if (channel.guildId !== this.guildId) {
261
+ if (!this.openChannels.has(channel.id))
262
+ return;
263
+ console.log(`[discord] classic channel deleted from non-primary guild ${channel.guildId} channel ${channel.id}`);
264
+ }
265
+ this.emit("topic_closed", {
266
+ chatId: this.guildId,
267
+ threadId: channel.id,
268
+ });
269
+ });
270
+ }
271
+ /** Mark channels as open (skip access control) — used for classic bot channels */
272
+ setOpenChannels(channelIds) {
273
+ this.openChannels = new Set(channelIds);
274
+ console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
275
+ }
276
+ // ── Lifecycle ──────────────────────────────────────────────────────────
277
+ async start() {
278
+ this.queue.start();
279
+ this.client.once("ready", async () => {
280
+ // Register classic bot slash commands
281
+ try {
282
+ await this.client.application?.commands.set([
283
+ { name: "start", description: "Start an agent in this channel" },
284
+ { name: "stop", description: "Stop the agent in this channel" },
285
+ {
286
+ name: "chat", description: "Send a message to the agent",
287
+ options: [{ name: "message", description: "Your message", type: 3, required: true }],
288
+ },
289
+ { name: "status", description: "Show fleet status and costs" },
290
+ { name: "sysinfo", description: "System diagnostics" },
291
+ { name: "ctx", description: "Show agent context usage" },
292
+ { name: "restart", description: "🔒 Graceful restart all instances" },
293
+ { name: "update", description: "🔒 Update AgEnD to latest version" },
294
+ { name: "doctor", description: "🔒 Run health diagnostics" },
295
+ { name: "compact", description: "🔒 Compact agent context window" },
296
+ { name: "collab", description: "🔒 Toggle bot/webhook collaboration mode" },
297
+ {
298
+ name: "save", description: "🔒 Save the agent's conversation",
299
+ options: [
300
+ { name: "filename", description: "File name to save as", type: 3, required: true },
301
+ { name: "force", description: "Overwrite if file exists", type: 5, required: false },
302
+ ],
303
+ },
304
+ {
305
+ name: "load", description: "🔒 Load a saved conversation",
306
+ options: [{ name: "filename", description: "File name to load", type: 3, required: true }],
307
+ },
308
+ ]);
309
+ }
310
+ catch (err) {
311
+ // Non-fatal — slash commands may fail on network issues
312
+ }
313
+ this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id);
314
+ });
315
+ await this.client.login(this.botToken);
316
+ }
317
+ async stop() {
318
+ this.queue.stop();
319
+ this.client.destroy();
320
+ }
321
+ // ── Text / file sending ────────────────────────────────────────────────
322
+ async sendText(chatId, text, opts) {
323
+ const channelId = opts?.threadId ?? chatId;
324
+ const channel = await this._fetchTextChannel(channelId);
325
+ const chunkLimit = opts?.chunkLimit ?? DISCORD_MAX_LENGTH;
326
+ const chunks = splitText(text, chunkLimit);
327
+ if (chunks.length === 0)
328
+ throw new Error("Empty text");
329
+ const first = await channel.send(chunks[0]);
330
+ // Enqueue remaining chunks
331
+ for (let i = 1; i < chunks.length; i++) {
332
+ this.queue.enqueue(chatId, opts?.threadId, { type: "content", text: chunks[i] });
333
+ }
334
+ return {
335
+ messageId: first.id,
336
+ chatId,
337
+ threadId: opts?.threadId,
338
+ };
339
+ }
340
+ async sendFile(chatId, filePath, opts) {
341
+ const channelId = opts?.threadId ?? chatId;
342
+ const channel = await this._fetchTextChannel(channelId);
343
+ const msg = await channel.send({ files: [filePath] });
344
+ return { messageId: msg.id, chatId, threadId: opts?.threadId };
345
+ }
346
+ async editMessage(chatId, messageId, text) {
347
+ // chatId is guild ID in channels topology, but messageId is in a channel.
348
+ // We need to find the message. Try all text channels in the guild.
349
+ // Optimization: caller usually provides the channel via sendText return value.
350
+ try {
351
+ // Try the general channel first, then search
352
+ const guild = await this.client.guilds.fetch(this.guildId);
353
+ const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
354
+ for (const [, ch] of channels) {
355
+ try {
356
+ const textCh = ch;
357
+ const msg = await textCh.messages.fetch(messageId);
358
+ await msg.edit(text.slice(0, DISCORD_MAX_LENGTH));
359
+ return;
360
+ }
361
+ catch {
362
+ continue;
363
+ }
364
+ }
365
+ throw new Error(`Message ${messageId} not found in any channel`);
366
+ }
367
+ catch (err) {
368
+ // Fallback: send a new message if edit fails
369
+ if (this.generalChannelId) {
370
+ const channel = await this._fetchTextChannel(this.generalChannelId);
371
+ await channel.send(text.slice(0, DISCORD_MAX_LENGTH));
372
+ }
373
+ }
374
+ }
375
+ async react(chatId, messageId, emoji) {
376
+ try {
377
+ // Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
378
+ const encoded = encodeURIComponent(emoji);
379
+ await this.client.rest.put(`/channels/${chatId}/messages/${messageId}/reactions/${encoded}/@me`);
380
+ }
381
+ catch {
382
+ // No-op per degradation strategy
383
+ }
384
+ }
385
+ // ── Approval ───────────────────────────────────────────────────────────
386
+ async sendApproval(prompt, callback, signal, threadId) {
387
+ const nonce = randomBytes(5).toString("hex");
388
+ const approveData = `approval:approve:${nonce}`;
389
+ const alwaysData = `approval:approve_always:${nonce}`;
390
+ const denyData = `approval:deny:${nonce}`;
391
+ const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
392
+ .setCustomId(approveData)
393
+ .setLabel("Allow")
394
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
395
+ .setCustomId(alwaysData)
396
+ .setLabel("Always")
397
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
398
+ .setCustomId(denyData)
399
+ .setLabel("Deny")
400
+ .setStyle(ButtonStyle.Danger));
401
+ let text = `⚠️ **Permission Request**\nTool: \`${prompt.tool_name}\``;
402
+ if (prompt.input_preview) {
403
+ const preview = prompt.input_preview.length > 200
404
+ ? prompt.input_preview.slice(0, 200) + "…"
405
+ : prompt.input_preview;
406
+ text += `\n\`\`\`\n${preview}\n\`\`\``;
407
+ }
408
+ else if (prompt.description) {
409
+ text += `\n${prompt.description}`;
410
+ }
411
+ const cleanup = () => {
412
+ this.off("callback_query", handler);
413
+ };
414
+ const handler = (query) => {
415
+ if (!query.callbackData)
416
+ return;
417
+ const isApprove = query.callbackData === approveData;
418
+ const isAlways = query.callbackData === alwaysData;
419
+ const isDeny = query.callbackData === denyData;
420
+ if (!isApprove && !isAlways && !isDeny)
421
+ return;
422
+ cleanup();
423
+ // Update the message to show the decision
424
+ if (query.threadId && query.messageId) {
425
+ this._fetchTextChannel(query.threadId).then((ch) => {
426
+ ch.messages.fetch(query.messageId).then((msg) => {
427
+ const label = isDeny ? "❌ Denied" : isAlways ? "✅ Always Allowed" : "✅ Allowed";
428
+ msg.edit({
429
+ content: `${label}\nTool: \`${prompt.tool_name}\``,
430
+ components: [],
431
+ }).catch(() => { });
432
+ }).catch(() => { });
433
+ }).catch(() => { });
434
+ }
435
+ callback(isDeny ? "deny" : isAlways ? "approve_always" : "approve");
436
+ };
437
+ this.on("callback_query", handler);
438
+ if (signal) {
439
+ signal.addEventListener("abort", () => cleanup());
440
+ }
441
+ const channelId = threadId ?? this.generalChannelId;
442
+ if (channelId) {
443
+ const channel = await this._fetchTextChannel(channelId);
444
+ await channel.send({ content: text, components: [row] });
445
+ }
446
+ else {
447
+ this.emit("approval_request", { prompt: text, components: [row], nonce });
448
+ }
449
+ return { cancel: cleanup };
450
+ }
451
+ // ── Chat ID management ──────────────────────────────────────────────────
452
+ getChatId() { return this.lastChatId; }
453
+ setChatId(chatId) { this.lastChatId = chatId; }
454
+ // ── File download ──────────────────────────────────────────────────────
455
+ async downloadAttachment(fileId) {
456
+ const url = this.attachmentUrls.get(fileId);
457
+ if (!url)
458
+ throw new Error(`No URL for attachment: ${fileId}`);
459
+ const response = await fetch(url);
460
+ if (!response.ok)
461
+ throw new Error(`Download failed: ${response.status}`);
462
+ const filename = `${Date.now()}-${fileId.slice(-8)}-${url.split("/").pop()?.split("?")[0] ?? "file"}`;
463
+ const localPath = join(this.inboxDir, filename);
464
+ const dest = createWriteStream(localPath);
465
+ const body = response.body;
466
+ if (!body)
467
+ throw new Error("No response body");
468
+ await pipeline(Readable.fromWeb(body), dest);
469
+ return localPath;
470
+ }
471
+ // ── Intent-oriented methods ──────────────────────────────────────────
472
+ async promptUser(chatId, text, choices, opts) {
473
+ const channelId = opts?.threadId ?? chatId;
474
+ const channel = await this._fetchTextChannel(channelId);
475
+ const row = new ActionRowBuilder();
476
+ for (const choice of choices) {
477
+ row.addComponents(new ButtonBuilder()
478
+ .setCustomId(choice.id)
479
+ .setLabel(choice.label.slice(0, 80)) // Discord button label max 80 chars
480
+ .setStyle(ButtonStyle.Primary));
481
+ }
482
+ const msg = await channel.send({ content: text, components: [row] });
483
+ return msg.id;
484
+ }
485
+ async notifyAlert(chatId, alert, opts) {
486
+ if (alert.choices && alert.choices.length > 0) {
487
+ const channelId = opts?.threadId ?? chatId;
488
+ const channel = await this._fetchTextChannel(channelId);
489
+ const row = new ActionRowBuilder();
490
+ for (const choice of alert.choices) {
491
+ row.addComponents(new ButtonBuilder()
492
+ .setCustomId(choice.id)
493
+ .setLabel(choice.label.slice(0, 80))
494
+ .setStyle(ButtonStyle.Secondary));
495
+ }
496
+ const msg = await channel.send({ content: alert.message, components: [row] });
497
+ return { messageId: msg.id, chatId, threadId: opts?.threadId };
498
+ }
499
+ return this.sendText(chatId, alert.message, opts);
500
+ }
501
+ // ── Topology: create channel ────────────────────────────────────────────
502
+ async _resolveCategory() {
503
+ const guild = await this.client.guilds.fetch(this.guildId);
504
+ await guild.channels.fetch();
505
+ const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
506
+ if (existing)
507
+ return existing.id;
508
+ const cat = await guild.channels.create({
509
+ name: this.categoryName,
510
+ type: ChannelType.GuildCategory,
511
+ });
512
+ return cat.id;
513
+ }
514
+ async ensureCategoryId() {
515
+ if (!this.categoryIdPromise) {
516
+ this.categoryIdPromise = this._resolveCategory().catch((err) => {
517
+ this.categoryIdPromise = undefined;
518
+ throw err;
519
+ });
520
+ }
521
+ return this.categoryIdPromise;
522
+ }
523
+ async createTopic(name) {
524
+ const guild = await this.client.guilds.fetch(this.guildId);
525
+ const categoryId = await this.ensureCategoryId();
526
+ try {
527
+ const channel = await guild.channels.create({
528
+ name,
529
+ type: ChannelType.GuildText,
530
+ parent: categoryId,
531
+ });
532
+ return channel.id;
533
+ }
534
+ catch (err) {
535
+ // 10003 = Unknown Channel — category was deleted externally
536
+ if (err.code === 10003) {
537
+ this.categoryIdPromise = undefined;
538
+ const freshId = await this.ensureCategoryId();
539
+ const channel = await guild.channels.create({
540
+ name,
541
+ type: ChannelType.GuildText,
542
+ parent: freshId,
543
+ });
544
+ return channel.id;
545
+ }
546
+ throw err;
547
+ }
548
+ }
549
+ async deleteTopic(topicId) {
550
+ const channel = await this.client.channels.fetch(String(topicId));
551
+ // Only delete GuildText channels created by createTopic — never categories or forums
552
+ if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
553
+ await channel.delete();
554
+ }
555
+ }
556
+ async topicExists(topicId) {
557
+ try {
558
+ const channel = await this.client.channels.fetch(String(topicId));
559
+ return channel != null;
560
+ }
561
+ catch {
562
+ return false;
563
+ }
564
+ }
565
+ // ── Pairing ────────────────────────────────────────────────────────────
566
+ async handlePairing(chatId, userId) {
567
+ const code = this.accessManager.generateCode(userId);
568
+ return code;
569
+ }
570
+ async confirmPairing(code, callerUserId) {
571
+ return this.accessManager.confirmCode(code, callerUserId);
572
+ }
573
+ }
574
+ // ── Helpers ──────────────────────────────────────────────────────────────
575
+ function splitText(text, limit) {
576
+ const chunks = [];
577
+ let offset = 0;
578
+ while (offset < text.length) {
579
+ chunks.push(text.slice(offset, offset + limit));
580
+ offset += limit;
581
+ }
582
+ return chunks;
583
+ }
584
+ //# sourceMappingURL=discord.js.map