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