@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.
- package/dist/discord-adapter.d.ts +53 -0
- package/dist/discord-adapter.js +614 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +11 -0
- package/package.json +24 -0
- package/src/discord-adapter.ts +694 -0
- package/src/index.ts +32 -0
- package/tsconfig.json +14 -0
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|