@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,53 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { ChannelAdapter, ApprovalHandle, SendOpts, SentMessage, PermissionPrompt, Choice, AlertData } from "@suzuke/agend/channel";
3
+ import type { AccessManager } from "@suzuke/agend/channel/access-manager";
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,614 @@
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 "@suzuke/agend/channel/message-queue";
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
+ // Collect stickers as photo attachments (skip Lottie format which is JSON)
141
+ for (const [, sticker] of msg.stickers) {
142
+ if (sticker.format === 3)
143
+ continue; // Lottie = not a raster image
144
+ const stickerUrl = sticker.url;
145
+ if (stickerUrl) {
146
+ const stickerId = `sticker-${sticker.id}`;
147
+ const ext = sticker.format === 4 ? "gif" : "png";
148
+ attachments.push({
149
+ kind: "photo",
150
+ fileId: stickerId,
151
+ mime: `image/${ext}`,
152
+ size: 0,
153
+ filename: `${sticker.name}.${ext}`,
154
+ });
155
+ this.attachmentUrls.set(stickerId, stickerUrl);
156
+ }
157
+ }
158
+ // Store attachment URLs for later download
159
+ for (const att of msg.attachments.values()) {
160
+ this.attachmentUrls.set(att.id, att.url);
161
+ }
162
+ while (this.attachmentUrls.size > 1000) {
163
+ const first = this.attachmentUrls.keys().next().value;
164
+ if (first)
165
+ this.attachmentUrls.delete(first);
166
+ else
167
+ break;
168
+ }
169
+ let replyToText;
170
+ if (msg.reference?.messageId) {
171
+ try {
172
+ const ref = await msg.fetchReference();
173
+ replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
174
+ }
175
+ catch { /* deleted message or no permission */ }
176
+ }
177
+ this.emit("message", {
178
+ source: "discord",
179
+ adapterId: this.id,
180
+ chatId,
181
+ threadId,
182
+ messageId,
183
+ userId,
184
+ username,
185
+ text,
186
+ timestamp: msg.createdAt,
187
+ isBotMessage,
188
+ attachments: attachments.length > 0 ? attachments : undefined,
189
+ replyTo: msg.reference?.messageId ?? undefined,
190
+ replyToText,
191
+ });
192
+ });
193
+ // Handle button interactions and slash commands
194
+ // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
195
+ // interaction expires (>3s). Catch to prevent crashing the entire daemon.
196
+ this.client.on("interactionCreate", async (interaction) => {
197
+ try {
198
+ if (interaction.guildId !== this.guildId) {
199
+ // Allow slash commands through — guild whitelist is checked by fleet-manager.
200
+ // Only block non-slash interactions (buttons) from unknown channels.
201
+ if (!interaction.isChatInputCommand() && !this.openChannels.has(interaction.channelId ?? ""))
202
+ return;
203
+ if (!interaction.isChatInputCommand()) {
204
+ console.log(`[discord] classic channel interaction from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
205
+ }
206
+ }
207
+ if (interaction.isButton()) {
208
+ await interaction.deferUpdate();
209
+ this.emit("callback_query", {
210
+ callbackData: interaction.customId,
211
+ chatId: this.guildId,
212
+ threadId: interaction.channelId,
213
+ messageId: interaction.message.id,
214
+ });
215
+ return;
216
+ }
217
+ if (interaction.isChatInputCommand()) {
218
+ const channelName = interaction.channel && "name" in interaction.channel ? (interaction.channel.name ?? "") : "";
219
+ const username = interaction.user.username;
220
+ if (interaction.commandName === "chat") {
221
+ const text = interaction.options.getString("message") ?? "";
222
+ await interaction.deferReply();
223
+ this.emit("slash_command", {
224
+ command: "chat",
225
+ channelId: interaction.channelId,
226
+ channelName,
227
+ guildId: interaction.guildId ?? undefined,
228
+ userId: interaction.user.id,
229
+ username,
230
+ text,
231
+ respond: async (reply) => { try {
232
+ const m = await interaction.editReply(reply);
233
+ return m.id;
234
+ }
235
+ catch {
236
+ return undefined;
237
+ } },
238
+ });
239
+ }
240
+ else {
241
+ await interaction.deferReply({ ephemeral: true });
242
+ // Extract options as key-value pairs for fleet-manager
243
+ const options = {};
244
+ for (const opt of interaction.options.data) {
245
+ options[opt.name] = opt.value;
246
+ }
247
+ this.emit("slash_command", {
248
+ command: interaction.commandName,
249
+ channelId: interaction.channelId,
250
+ channelName,
251
+ guildId: interaction.guildId ?? undefined,
252
+ userId: interaction.user.id,
253
+ username,
254
+ options,
255
+ respond: async (reply) => { try {
256
+ await interaction.editReply(reply);
257
+ }
258
+ catch { /* expired */ } },
259
+ });
260
+ }
261
+ }
262
+ }
263
+ catch (err) {
264
+ console.warn(`[discord] interactionCreate error (${err.message})`);
265
+ }
266
+ });
267
+ // Handle channel deletion (equivalent to topic_closed)
268
+ this.client.on("guildCreate", (guild) => {
269
+ this.emit("new_group_detected", {
270
+ groupId: guild.id,
271
+ groupTitle: guild.name,
272
+ source: "discord",
273
+ });
274
+ });
275
+ this.client.on("channelDelete", (channel) => {
276
+ if (!("guildId" in channel))
277
+ return;
278
+ if (channel.guildId !== this.guildId) {
279
+ if (!this.openChannels.has(channel.id))
280
+ return;
281
+ console.log(`[discord] classic channel deleted from non-primary guild ${channel.guildId} channel ${channel.id}`);
282
+ }
283
+ this.emit("topic_closed", {
284
+ chatId: this.guildId,
285
+ threadId: channel.id,
286
+ });
287
+ });
288
+ }
289
+ /** Mark channels as open (skip access control) — used for classic bot channels */
290
+ setOpenChannels(channelIds) {
291
+ this.openChannels = new Set(channelIds);
292
+ console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
293
+ }
294
+ // ── Lifecycle ──────────────────────────────────────────────────────────
295
+ async start() {
296
+ this.queue.start();
297
+ this.client.once("ready", async () => {
298
+ // Register classic bot slash commands
299
+ try {
300
+ await this.client.application?.commands.set([
301
+ { name: "start", description: "Start an agent in this channel" },
302
+ { name: "stop", description: "Stop the agent in this channel" },
303
+ {
304
+ name: "chat", description: "Send a message to the agent",
305
+ options: [{ name: "message", description: "Your message", type: 3, required: true }],
306
+ },
307
+ { name: "compact", description: "Compact the agent's context window" },
308
+ {
309
+ name: "save", description: "Save the agent's conversation",
310
+ options: [
311
+ { name: "filename", description: "File name to save as", type: 3, required: true },
312
+ { name: "force", description: "Overwrite if file exists", type: 5, required: false },
313
+ ],
314
+ },
315
+ {
316
+ name: "load", description: "Load a saved conversation",
317
+ options: [{ name: "filename", description: "File name to load", type: 3, required: true }],
318
+ },
319
+ { name: "ctx", description: "Show agent context usage" },
320
+ { name: "collab", description: "Toggle collaboration mode (@mention trigger)" },
321
+ ]);
322
+ }
323
+ catch (err) {
324
+ // Non-fatal — slash commands may fail on network issues
325
+ }
326
+ this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id);
327
+ });
328
+ await this.client.login(this.botToken);
329
+ }
330
+ async stop() {
331
+ this.queue.stop();
332
+ this.client.destroy();
333
+ }
334
+ // ── Text / file sending ────────────────────────────────────────────────
335
+ async sendText(chatId, text, opts) {
336
+ const channelId = opts?.threadId ?? chatId;
337
+ const channel = await this._fetchTextChannel(channelId);
338
+ const chunkLimit = opts?.chunkLimit ?? DISCORD_MAX_LENGTH;
339
+ const chunks = splitText(text, chunkLimit);
340
+ if (chunks.length === 0)
341
+ throw new Error("Empty text");
342
+ const first = await channel.send(chunks[0]);
343
+ // Enqueue remaining chunks
344
+ for (let i = 1; i < chunks.length; i++) {
345
+ this.queue.enqueue(chatId, opts?.threadId, { type: "content", text: chunks[i] });
346
+ }
347
+ return {
348
+ messageId: first.id,
349
+ chatId,
350
+ threadId: opts?.threadId,
351
+ };
352
+ }
353
+ async sendFile(chatId, filePath, opts) {
354
+ const channelId = opts?.threadId ?? chatId;
355
+ const channel = await this._fetchTextChannel(channelId);
356
+ const msg = await channel.send({ files: [filePath] });
357
+ return { messageId: msg.id, chatId, threadId: opts?.threadId };
358
+ }
359
+ async editMessage(chatId, messageId, text) {
360
+ // chatId is guild ID in channels topology, but messageId is in a channel.
361
+ // We need to find the message. Try all text channels in the guild.
362
+ // Optimization: caller usually provides the channel via sendText return value.
363
+ try {
364
+ // Try the general channel first, then search
365
+ const guild = await this.client.guilds.fetch(this.guildId);
366
+ const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
367
+ for (const [, ch] of channels) {
368
+ try {
369
+ const textCh = ch;
370
+ const msg = await textCh.messages.fetch(messageId);
371
+ await msg.edit(text.slice(0, DISCORD_MAX_LENGTH));
372
+ return;
373
+ }
374
+ catch {
375
+ continue;
376
+ }
377
+ }
378
+ throw new Error(`Message ${messageId} not found in any channel`);
379
+ }
380
+ catch (err) {
381
+ // Fallback: send a new message if edit fails
382
+ if (this.generalChannelId) {
383
+ const channel = await this._fetchTextChannel(this.generalChannelId);
384
+ await channel.send(text.slice(0, DISCORD_MAX_LENGTH));
385
+ }
386
+ }
387
+ }
388
+ async react(chatId, messageId, emoji) {
389
+ try {
390
+ // Try chatId as channel ID first (Discord classic bot passes channelId as chatId)
391
+ try {
392
+ const ch = await this._fetchTextChannel(chatId);
393
+ const msg = await ch.messages.fetch(messageId);
394
+ await msg.react(emoji);
395
+ return;
396
+ }
397
+ catch { /* not found in this channel, search guild */ }
398
+ const guild = await this.client.guilds.fetch(this.guildId);
399
+ const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
400
+ for (const [, ch] of channels) {
401
+ try {
402
+ const textCh = ch;
403
+ const msg = await textCh.messages.fetch(messageId);
404
+ await msg.react(emoji);
405
+ return;
406
+ }
407
+ catch {
408
+ continue;
409
+ }
410
+ }
411
+ }
412
+ catch {
413
+ // No-op per degradation strategy
414
+ }
415
+ }
416
+ // ── Approval ───────────────────────────────────────────────────────────
417
+ async sendApproval(prompt, callback, signal, threadId) {
418
+ const nonce = randomBytes(5).toString("hex");
419
+ const approveData = `approval:approve:${nonce}`;
420
+ const alwaysData = `approval:approve_always:${nonce}`;
421
+ const denyData = `approval:deny:${nonce}`;
422
+ const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
423
+ .setCustomId(approveData)
424
+ .setLabel("Allow")
425
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
426
+ .setCustomId(alwaysData)
427
+ .setLabel("Always")
428
+ .setStyle(ButtonStyle.Success), new ButtonBuilder()
429
+ .setCustomId(denyData)
430
+ .setLabel("Deny")
431
+ .setStyle(ButtonStyle.Danger));
432
+ let text = `⚠️ **Permission Request**\nTool: \`${prompt.tool_name}\``;
433
+ if (prompt.input_preview) {
434
+ const preview = prompt.input_preview.length > 200
435
+ ? prompt.input_preview.slice(0, 200) + "…"
436
+ : prompt.input_preview;
437
+ text += `\n\`\`\`\n${preview}\n\`\`\``;
438
+ }
439
+ else if (prompt.description) {
440
+ text += `\n${prompt.description}`;
441
+ }
442
+ const cleanup = () => {
443
+ this.off("callback_query", handler);
444
+ };
445
+ const handler = (query) => {
446
+ if (!query.callbackData)
447
+ return;
448
+ const isApprove = query.callbackData === approveData;
449
+ const isAlways = query.callbackData === alwaysData;
450
+ const isDeny = query.callbackData === denyData;
451
+ if (!isApprove && !isAlways && !isDeny)
452
+ return;
453
+ cleanup();
454
+ // Update the message to show the decision
455
+ if (query.threadId && query.messageId) {
456
+ this._fetchTextChannel(query.threadId).then((ch) => {
457
+ ch.messages.fetch(query.messageId).then((msg) => {
458
+ const label = isDeny ? "❌ Denied" : isAlways ? "✅ Always Allowed" : "✅ Allowed";
459
+ msg.edit({
460
+ content: `${label}\nTool: \`${prompt.tool_name}\``,
461
+ components: [],
462
+ }).catch(() => { });
463
+ }).catch(() => { });
464
+ }).catch(() => { });
465
+ }
466
+ callback(isDeny ? "deny" : isAlways ? "approve_always" : "approve");
467
+ };
468
+ this.on("callback_query", handler);
469
+ if (signal) {
470
+ signal.addEventListener("abort", () => cleanup());
471
+ }
472
+ const channelId = threadId ?? this.generalChannelId;
473
+ if (channelId) {
474
+ const channel = await this._fetchTextChannel(channelId);
475
+ await channel.send({ content: text, components: [row] });
476
+ }
477
+ else {
478
+ this.emit("approval_request", { prompt: text, components: [row], nonce });
479
+ }
480
+ return { cancel: cleanup };
481
+ }
482
+ // ── Chat ID management ──────────────────────────────────────────────────
483
+ getChatId() { return this.lastChatId; }
484
+ setChatId(chatId) { this.lastChatId = chatId; }
485
+ // ── File download ──────────────────────────────────────────────────────
486
+ async downloadAttachment(fileId) {
487
+ const url = this.attachmentUrls.get(fileId);
488
+ if (!url)
489
+ throw new Error(`No URL for attachment: ${fileId}`);
490
+ const response = await fetch(url);
491
+ if (!response.ok)
492
+ throw new Error(`Download failed: ${response.status}`);
493
+ const filename = fileId + "_" + (url.split("/").pop()?.split("?")[0] ?? "file");
494
+ const localPath = join(this.inboxDir, filename);
495
+ const dest = createWriteStream(localPath);
496
+ const body = response.body;
497
+ if (!body)
498
+ throw new Error("No response body");
499
+ await pipeline(Readable.fromWeb(body), dest);
500
+ return localPath;
501
+ }
502
+ // ── Intent-oriented methods ──────────────────────────────────────────
503
+ async promptUser(chatId, text, choices, opts) {
504
+ const channelId = opts?.threadId ?? chatId;
505
+ const channel = await this._fetchTextChannel(channelId);
506
+ const row = new ActionRowBuilder();
507
+ for (const choice of choices) {
508
+ row.addComponents(new ButtonBuilder()
509
+ .setCustomId(choice.id)
510
+ .setLabel(choice.label.slice(0, 80)) // Discord button label max 80 chars
511
+ .setStyle(ButtonStyle.Primary));
512
+ }
513
+ const msg = await channel.send({ content: text, components: [row] });
514
+ return msg.id;
515
+ }
516
+ async notifyAlert(chatId, alert, opts) {
517
+ if (alert.choices && alert.choices.length > 0) {
518
+ const channelId = opts?.threadId ?? chatId;
519
+ const channel = await this._fetchTextChannel(channelId);
520
+ const row = new ActionRowBuilder();
521
+ for (const choice of alert.choices) {
522
+ row.addComponents(new ButtonBuilder()
523
+ .setCustomId(choice.id)
524
+ .setLabel(choice.label.slice(0, 80))
525
+ .setStyle(ButtonStyle.Secondary));
526
+ }
527
+ const msg = await channel.send({ content: alert.message, components: [row] });
528
+ return { messageId: msg.id, chatId, threadId: opts?.threadId };
529
+ }
530
+ return this.sendText(chatId, alert.message, opts);
531
+ }
532
+ // ── Topology: create channel ────────────────────────────────────────────
533
+ async _resolveCategory() {
534
+ const guild = await this.client.guilds.fetch(this.guildId);
535
+ await guild.channels.fetch();
536
+ const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
537
+ if (existing)
538
+ return existing.id;
539
+ const cat = await guild.channels.create({
540
+ name: this.categoryName,
541
+ type: ChannelType.GuildCategory,
542
+ });
543
+ return cat.id;
544
+ }
545
+ async ensureCategoryId() {
546
+ if (!this.categoryIdPromise) {
547
+ this.categoryIdPromise = this._resolveCategory().catch((err) => {
548
+ this.categoryIdPromise = undefined;
549
+ throw err;
550
+ });
551
+ }
552
+ return this.categoryIdPromise;
553
+ }
554
+ async createTopic(name) {
555
+ const guild = await this.client.guilds.fetch(this.guildId);
556
+ const categoryId = await this.ensureCategoryId();
557
+ try {
558
+ const channel = await guild.channels.create({
559
+ name,
560
+ type: ChannelType.GuildText,
561
+ parent: categoryId,
562
+ });
563
+ return channel.id;
564
+ }
565
+ catch (err) {
566
+ // 10003 = Unknown Channel — category was deleted externally
567
+ if (err.code === 10003) {
568
+ this.categoryIdPromise = undefined;
569
+ const freshId = await this.ensureCategoryId();
570
+ const channel = await guild.channels.create({
571
+ name,
572
+ type: ChannelType.GuildText,
573
+ parent: freshId,
574
+ });
575
+ return channel.id;
576
+ }
577
+ throw err;
578
+ }
579
+ }
580
+ async deleteTopic(topicId) {
581
+ const channel = await this.client.channels.fetch(String(topicId));
582
+ // Only delete GuildText channels created by createTopic — never categories or forums
583
+ if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
584
+ await channel.delete();
585
+ }
586
+ }
587
+ async topicExists(topicId) {
588
+ try {
589
+ const channel = await this.client.channels.fetch(String(topicId));
590
+ return channel != null;
591
+ }
592
+ catch {
593
+ return false;
594
+ }
595
+ }
596
+ // ── Pairing ────────────────────────────────────────────────────────────
597
+ async handlePairing(chatId, userId) {
598
+ const code = this.accessManager.generateCode(userId);
599
+ return code;
600
+ }
601
+ async confirmPairing(code, callerUserId) {
602
+ return this.accessManager.confirmCode(code, callerUserId);
603
+ }
604
+ }
605
+ // ── Helpers ──────────────────────────────────────────────────────────────
606
+ function splitText(text, limit) {
607
+ const chunks = [];
608
+ let offset = 0;
609
+ while (offset < text.length) {
610
+ chunks.push(text.slice(offset, offset + limit));
611
+ offset += limit;
612
+ }
613
+ return chunks;
614
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * AgEnD Discord adapter plugin.
3
+ *
4
+ * Install: npm install agend-plugin-discord
5
+ *
6
+ * fleet.yaml:
7
+ * channel:
8
+ * type: discord
9
+ */
10
+ import type { ChannelAdapter } from "@suzuke/agend/channel";
11
+ import type { ChannelConfig } from "@suzuke/agend/types";
12
+ import type { AccessManager } from "@suzuke/agend/channel/access-manager";
13
+ interface AdapterOpts {
14
+ id: string;
15
+ botToken: string;
16
+ accessManager: AccessManager;
17
+ inboxDir: string;
18
+ }
19
+ /** Plugin factory — default export for AgEnD plugin loader. */
20
+ export default function createAdapter(config: ChannelConfig, opts: AdapterOpts): ChannelAdapter;
21
+ export { DiscordAdapter } from "./discord-adapter.js";