@agentforge-ai/channels-discord 0.6.0

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/index.js ADDED
@@ -0,0 +1,875 @@
1
+ // src/discord-adapter.ts
2
+ import {
3
+ Client,
4
+ GatewayIntentBits,
5
+ Partials,
6
+ Routes,
7
+ ChannelType,
8
+ Events
9
+ } from "discord.js";
10
+ import { REST } from "@discordjs/rest";
11
+ import {
12
+ ChannelAdapter,
13
+ MessageNormalizer
14
+ } from "@agentforge-ai/core";
15
+
16
+ // src/types.ts
17
+ var DISCORD_BOT_COMMANDS = [
18
+ { name: "start", description: "Start a new conversation with the AI agent" },
19
+ { name: "new", description: "Reset the current conversation thread" },
20
+ { name: "help", description: "Show available commands and usage information" }
21
+ ];
22
+
23
+ // src/discord-adapter.ts
24
+ var DiscordAdapter = class extends ChannelAdapter {
25
+ platform = "discord";
26
+ client = null;
27
+ adapterConfig = null;
28
+ botUserId = "";
29
+ // Rate limiting
30
+ messageTimestamps = [];
31
+ rateLimitPerSecond = 5;
32
+ // ----- Lifecycle -----
33
+ async connect(config) {
34
+ const botToken = config.credentials.botToken;
35
+ if (!botToken) {
36
+ throw new Error("Discord bot token is required in credentials.botToken");
37
+ }
38
+ this.adapterConfig = {
39
+ botToken,
40
+ clientId: config.credentials.clientId ?? config.settings?.clientId,
41
+ guildId: config.credentials.guildId ?? config.settings?.guildId,
42
+ registerCommands: config.settings?.registerCommands ?? true,
43
+ mentionOnly: config.settings?.mentionOnly ?? false,
44
+ respondToDMs: config.settings?.respondToDMs ?? true,
45
+ rateLimitPerSecond: config.settings?.rateLimitPerSecond ?? 5
46
+ };
47
+ this.rateLimitPerSecond = this.adapterConfig.rateLimitPerSecond;
48
+ const intents = [
49
+ GatewayIntentBits.Guilds,
50
+ GatewayIntentBits.GuildMessages,
51
+ GatewayIntentBits.MessageContent,
52
+ GatewayIntentBits.DirectMessages,
53
+ GatewayIntentBits.GuildMessageReactions
54
+ ];
55
+ this.client = new Client({
56
+ intents,
57
+ partials: [Partials.Channel, Partials.Message]
58
+ });
59
+ this.registerClientEvents();
60
+ await this.client.login(botToken);
61
+ await this.waitForReady();
62
+ if (this.adapterConfig.registerCommands && this.adapterConfig.clientId) {
63
+ await this.registerSlashCommands(
64
+ botToken,
65
+ this.adapterConfig.clientId,
66
+ DISCORD_BOT_COMMANDS,
67
+ this.adapterConfig.guildId
68
+ );
69
+ }
70
+ }
71
+ async disconnect() {
72
+ if (this.client) {
73
+ this.client.removeAllListeners();
74
+ this.client.destroy();
75
+ this.client = null;
76
+ }
77
+ this.botUserId = "";
78
+ this.adapterConfig = null;
79
+ }
80
+ // ----- Message Sending -----
81
+ async sendMessage(message) {
82
+ try {
83
+ await this.enforceRateLimit();
84
+ const targetChannelId = message.threadId ?? message.chatId;
85
+ const channel = await this.resolveChannel(targetChannelId);
86
+ if (!channel) {
87
+ return { success: false, error: `Channel ${targetChannelId} not found` };
88
+ }
89
+ if (message.showTyping) {
90
+ await this.sendTypingIndicator(message.chatId);
91
+ if (message.typingDurationMs) {
92
+ await this.sleep(message.typingDurationMs);
93
+ }
94
+ }
95
+ const sendableChannel = channel;
96
+ const payload = this.buildMessagePayload(message);
97
+ const sent = await sendableChannel.send(payload);
98
+ return {
99
+ success: true,
100
+ platformMessageId: sent.id,
101
+ deliveredAt: sent.createdAt
102
+ };
103
+ } catch (error) {
104
+ return {
105
+ success: false,
106
+ error: error instanceof Error ? error.message : String(error)
107
+ };
108
+ }
109
+ }
110
+ // ----- Capabilities -----
111
+ getCapabilities() {
112
+ return {
113
+ supportedMedia: ["image", "audio", "video", "file"],
114
+ maxTextLength: 2e3,
115
+ supportsThreads: true,
116
+ supportsReactions: true,
117
+ supportsEditing: true,
118
+ supportsDeleting: true,
119
+ supportsTypingIndicator: true,
120
+ supportsReadReceipts: false,
121
+ supportsActions: false,
122
+ supportsGroupChat: true,
123
+ supportsMarkdown: true,
124
+ maxFileSize: 25 * 1024 * 1024,
125
+ // 25MB
126
+ platformSpecific: {
127
+ supportsEmbeds: true,
128
+ supportsSlashCommands: true,
129
+ supportsThreads: true,
130
+ supportsForums: true,
131
+ supportsNitroFileSize: 500 * 1024 * 1024
132
+ // 500MB for Nitro servers
133
+ }
134
+ };
135
+ }
136
+ // ----- Health Check -----
137
+ async healthCheck() {
138
+ if (!this.client || !this.client.isReady()) {
139
+ return { status: "disconnected", details: "Discord client is not ready" };
140
+ }
141
+ try {
142
+ const user = this.client.user;
143
+ if (user) {
144
+ return {
145
+ status: "healthy",
146
+ details: `Bot ${user.tag} (ID: ${user.id}) is connected`
147
+ };
148
+ }
149
+ return { status: "degraded", details: "Client ready but user is null" };
150
+ } catch (error) {
151
+ return {
152
+ status: "unhealthy",
153
+ details: error instanceof Error ? error.message : String(error)
154
+ };
155
+ }
156
+ }
157
+ // ----- Optional Overrides -----
158
+ async editMessage(platformMessageId, message) {
159
+ try {
160
+ if (!message.text && !message.platformOptions?.embeds) {
161
+ return { success: false, error: "Text or embeds are required for editing" };
162
+ }
163
+ const targetChannelId = message.threadId ?? message.chatId;
164
+ if (!targetChannelId) {
165
+ return { success: false, error: "chatId is required for editing" };
166
+ }
167
+ const channel = await this.resolveChannel(targetChannelId);
168
+ if (!channel) {
169
+ return { success: false, error: `Channel ${targetChannelId} not found` };
170
+ }
171
+ const textChannel = channel;
172
+ const msg = await textChannel.messages.fetch(platformMessageId);
173
+ const editPayload = this.buildMessagePayload(message);
174
+ await msg.edit(editPayload);
175
+ return { success: true, platformMessageId };
176
+ } catch (error) {
177
+ return {
178
+ success: false,
179
+ error: error instanceof Error ? error.message : String(error)
180
+ };
181
+ }
182
+ }
183
+ async deleteMessage(platformMessageId, chatId) {
184
+ try {
185
+ const channel = await this.resolveChannel(chatId);
186
+ if (!channel) return false;
187
+ const textChannel = channel;
188
+ const msg = await textChannel.messages.fetch(platformMessageId);
189
+ await msg.delete();
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+ async sendTypingIndicator(chatId) {
196
+ try {
197
+ const channel = await this.resolveChannel(chatId);
198
+ if (!channel) return;
199
+ const sendable = channel;
200
+ await sendable.sendTyping();
201
+ } catch {
202
+ }
203
+ }
204
+ async addReaction(platformMessageId, chatId, emoji) {
205
+ try {
206
+ const channel = await this.resolveChannel(chatId);
207
+ if (!channel) return false;
208
+ const textChannel = channel;
209
+ const msg = await textChannel.messages.fetch(platformMessageId);
210
+ await msg.react(emoji);
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+ // ----- Internal: Client Events -----
217
+ registerClientEvents() {
218
+ if (!this.client) return;
219
+ this.client.on(Events.MessageCreate, (message) => {
220
+ this.handleDiscordMessage(message);
221
+ });
222
+ this.client.on(Events.MessageUpdate, (_old, newMessage) => {
223
+ if (newMessage.partial) return;
224
+ this.handleDiscordMessage(newMessage, true);
225
+ });
226
+ this.client.on(Events.InteractionCreate, (interaction) => {
227
+ if (interaction.isChatInputCommand()) {
228
+ this.handleSlashCommand(interaction);
229
+ }
230
+ });
231
+ this.client.on(Events.Error, (error) => {
232
+ this.emit({
233
+ type: "error",
234
+ data: { error, recoverable: true }
235
+ });
236
+ });
237
+ }
238
+ // ----- Internal: Message Handling -----
239
+ handleDiscordMessage(message, isEdit = false) {
240
+ if (message.author.id === this.botUserId) return;
241
+ if (message.author.bot) return;
242
+ if (message.guild && this.adapterConfig?.mentionOnly) {
243
+ if (!this.isBotMentioned(message)) return;
244
+ }
245
+ if (!message.guild && !this.adapterConfig?.respondToDMs) return;
246
+ const normalized = this.normalizeDiscordMessage(message, isEdit);
247
+ if (isEdit) {
248
+ this.emit({ type: "message_edited", data: normalized });
249
+ } else {
250
+ this.emitMessage(normalized);
251
+ }
252
+ }
253
+ handleSlashCommand(interaction) {
254
+ const normalized = this.normalizeSlashCommand(interaction);
255
+ this.emitMessage(normalized);
256
+ }
257
+ // ----- Internal: Message Normalization -----
258
+ normalizeDiscordMessage(message, isEdit) {
259
+ const chatId = message.channelId;
260
+ const threadId = message.thread?.id;
261
+ const chatType = this.mapChannelType(message.channel);
262
+ let text = message.content;
263
+ if (this.botUserId && text) {
264
+ text = text.replace(new RegExp(`<@!?${this.botUserId}>`, "g"), "").trim();
265
+ }
266
+ const media = this.extractMedia(message);
267
+ return MessageNormalizer.normalize({
268
+ platformMessageId: message.id,
269
+ channelId: this.config?.id ?? "",
270
+ platform: "discord",
271
+ chatId,
272
+ chatType,
273
+ senderId: message.author.id,
274
+ senderName: message.member?.displayName ?? message.author.displayName,
275
+ senderUsername: message.author.username,
276
+ senderAvatar: message.author.displayAvatarURL(),
277
+ text: text || void 0,
278
+ media,
279
+ replyToId: message.reference?.messageId,
280
+ threadId: threadId ?? (chatType === "thread" ? chatId : void 0),
281
+ rawData: {
282
+ guildId: message.guildId,
283
+ channelId: message.channelId,
284
+ messageType: message.type
285
+ },
286
+ timestamp: message.createdAt,
287
+ isEdit
288
+ });
289
+ }
290
+ normalizeSlashCommand(interaction) {
291
+ const chatType = this.mapChannelType(interaction.channel);
292
+ return MessageNormalizer.normalize({
293
+ platformMessageId: interaction.id,
294
+ channelId: this.config?.id ?? "",
295
+ platform: "discord",
296
+ chatId: interaction.channelId,
297
+ chatType,
298
+ senderId: interaction.user.id,
299
+ senderName: interaction.member ? interaction.member.displayName ?? interaction.user.displayName : interaction.user.displayName,
300
+ senderUsername: interaction.user.username,
301
+ senderAvatar: interaction.user.displayAvatarURL(),
302
+ text: `/${interaction.commandName}`,
303
+ rawData: {
304
+ guildId: interaction.guildId,
305
+ channelId: interaction.channelId,
306
+ interactionId: interaction.id,
307
+ commandName: interaction.commandName,
308
+ isSlashCommand: true
309
+ },
310
+ timestamp: interaction.createdAt
311
+ });
312
+ }
313
+ extractMedia(message) {
314
+ if (message.attachments.size === 0) return void 0;
315
+ const media = [];
316
+ for (const attachment of message.attachments.values()) {
317
+ const mimeType = attachment.contentType ?? void 0;
318
+ const mediaType = this.mapMimeTypeToMediaType(mimeType);
319
+ media.push({
320
+ type: mediaType,
321
+ url: attachment.url,
322
+ fileName: attachment.name ?? void 0,
323
+ mimeType,
324
+ sizeBytes: attachment.size,
325
+ width: attachment.width ?? void 0,
326
+ height: attachment.height ?? void 0
327
+ });
328
+ }
329
+ return media.length > 0 ? media : void 0;
330
+ }
331
+ mapMimeTypeToMediaType(mimeType) {
332
+ if (!mimeType) return "file";
333
+ if (mimeType.startsWith("image/")) return "image";
334
+ if (mimeType.startsWith("audio/")) return "audio";
335
+ if (mimeType.startsWith("video/")) return "video";
336
+ return "file";
337
+ }
338
+ mapChannelType(channel) {
339
+ if (!channel) return "channel";
340
+ switch (channel.type) {
341
+ case ChannelType.DM:
342
+ case ChannelType.GroupDM:
343
+ return "dm";
344
+ case ChannelType.PublicThread:
345
+ case ChannelType.PrivateThread:
346
+ case ChannelType.AnnouncementThread:
347
+ return "thread";
348
+ case ChannelType.GuildText:
349
+ case ChannelType.GuildAnnouncement:
350
+ case ChannelType.GuildForum:
351
+ case ChannelType.GuildVoice:
352
+ return "channel";
353
+ default:
354
+ return "channel";
355
+ }
356
+ }
357
+ // ----- Internal: Message Payload Builder -----
358
+ buildMessagePayload(message) {
359
+ const payload = {};
360
+ if (message.text) {
361
+ payload["content"] = message.text;
362
+ }
363
+ const embeds = message.platformOptions?.embeds;
364
+ if (embeds) {
365
+ payload["embeds"] = embeds;
366
+ }
367
+ if (message.replyToMessageId) {
368
+ payload["reply"] = { messageReference: message.replyToMessageId };
369
+ }
370
+ return payload;
371
+ }
372
+ // ----- Internal: @Mention Detection -----
373
+ isBotMentioned(message) {
374
+ if (!this.botUserId) return false;
375
+ return message.mentions.users.has(this.botUserId);
376
+ }
377
+ // ----- Internal: Channel Resolution -----
378
+ async resolveChannel(channelId) {
379
+ if (!this.client) return null;
380
+ try {
381
+ const cached = this.client.channels.cache.get(channelId);
382
+ if (cached) return cached;
383
+ return await this.client.channels.fetch(channelId);
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+ // ----- Internal: Slash Command Registration -----
389
+ async registerSlashCommands(botToken, clientId, commands, guildId) {
390
+ try {
391
+ const rest = new REST({ version: "10" }).setToken(botToken);
392
+ const route = guildId ? Routes.applicationGuildCommands(clientId, guildId) : Routes.applicationCommands(clientId);
393
+ await rest.put(route, { body: commands });
394
+ } catch {
395
+ this.emit({
396
+ type: "error",
397
+ data: {
398
+ error: new Error("Failed to register Discord slash commands"),
399
+ recoverable: true
400
+ }
401
+ });
402
+ }
403
+ }
404
+ // ----- Internal: Ready Wait -----
405
+ waitForReady() {
406
+ return new Promise((resolve, reject) => {
407
+ if (!this.client) {
408
+ reject(new Error("Discord client is not initialized"));
409
+ return;
410
+ }
411
+ if (this.client.isReady()) {
412
+ this.botUserId = this.client.user.id;
413
+ resolve();
414
+ return;
415
+ }
416
+ const onReady = () => {
417
+ this.botUserId = this.client.user.id;
418
+ resolve();
419
+ };
420
+ const onError = (error) => {
421
+ reject(error);
422
+ };
423
+ this.client.once(Events.ClientReady, onReady);
424
+ this.client.once(Events.Error, onError);
425
+ const timeout = setTimeout(() => {
426
+ this.client?.off(Events.ClientReady, onReady);
427
+ this.client?.off(Events.Error, onError);
428
+ reject(new Error("Discord client did not become ready within 30 seconds"));
429
+ }, 3e4);
430
+ this.client.once(Events.ClientReady, () => clearTimeout(timeout));
431
+ });
432
+ }
433
+ // ----- Internal: Rate Limiting -----
434
+ async enforceRateLimit() {
435
+ const now = Date.now();
436
+ this.messageTimestamps = this.messageTimestamps.filter((t) => now - t < 1e3);
437
+ if (this.messageTimestamps.length >= this.rateLimitPerSecond) {
438
+ const oldestInWindow = this.messageTimestamps[0];
439
+ const waitMs = 1e3 - (now - oldestInWindow);
440
+ if (waitMs > 0) {
441
+ await this.sleep(waitMs);
442
+ }
443
+ }
444
+ this.messageTimestamps.push(Date.now());
445
+ }
446
+ // ----- Utility -----
447
+ sleep(ms) {
448
+ return new Promise((resolve) => setTimeout(resolve, ms));
449
+ }
450
+ };
451
+
452
+ // src/discord-channel.ts
453
+ var LOG_LEVELS = {
454
+ debug: 0,
455
+ info: 1,
456
+ warn: 2,
457
+ error: 3
458
+ };
459
+ function createLogger(level = "info") {
460
+ const threshold = LOG_LEVELS[level] ?? 1;
461
+ const prefix = "[agentforge:discord]";
462
+ return {
463
+ debug: (...args) => {
464
+ if (threshold <= 0) console.log(`${prefix} [DEBUG]`, ...args);
465
+ },
466
+ info: (...args) => {
467
+ if (threshold <= 1) console.log(`${prefix}`, ...args);
468
+ },
469
+ warn: (...args) => {
470
+ if (threshold <= 2) console.warn(`${prefix} [WARN]`, ...args);
471
+ },
472
+ error: (...args) => {
473
+ if (threshold <= 3) console.error(`${prefix} [ERROR]`, ...args);
474
+ }
475
+ };
476
+ }
477
+ var ConvexHttpApi = class {
478
+ baseUrl;
479
+ constructor(deploymentUrl) {
480
+ this.baseUrl = deploymentUrl.replace(/\/$/, "");
481
+ }
482
+ async query(functionPath, args = {}) {
483
+ const url = `${this.baseUrl}/api/query`;
484
+ const response = await fetch(url, {
485
+ method: "POST",
486
+ headers: { "Content-Type": "application/json" },
487
+ body: JSON.stringify({ path: functionPath, args })
488
+ });
489
+ if (!response.ok) {
490
+ const text = await response.text();
491
+ throw new Error(`Convex query ${functionPath} failed: ${response.status} ${text}`);
492
+ }
493
+ const data = await response.json();
494
+ if (data.status === "error") {
495
+ throw new Error(`Convex query ${functionPath} error: ${data.errorMessage}`);
496
+ }
497
+ return data.value;
498
+ }
499
+ async mutation(functionPath, args = {}) {
500
+ const url = `${this.baseUrl}/api/mutation`;
501
+ const response = await fetch(url, {
502
+ method: "POST",
503
+ headers: { "Content-Type": "application/json" },
504
+ body: JSON.stringify({ path: functionPath, args })
505
+ });
506
+ if (!response.ok) {
507
+ const text = await response.text();
508
+ throw new Error(`Convex mutation ${functionPath} failed: ${response.status} ${text}`);
509
+ }
510
+ const data = await response.json();
511
+ if (data.status === "error") {
512
+ throw new Error(`Convex mutation ${functionPath} error: ${data.errorMessage}`);
513
+ }
514
+ return data.value;
515
+ }
516
+ async action(functionPath, args = {}) {
517
+ const url = `${this.baseUrl}/api/action`;
518
+ const response = await fetch(url, {
519
+ method: "POST",
520
+ headers: { "Content-Type": "application/json" },
521
+ body: JSON.stringify({ path: functionPath, args })
522
+ });
523
+ if (!response.ok) {
524
+ const text = await response.text();
525
+ throw new Error(`Convex action ${functionPath} failed: ${response.status} ${text}`);
526
+ }
527
+ const data = await response.json();
528
+ if (data.status === "error") {
529
+ throw new Error(`Convex action ${functionPath} error: ${data.errorMessage}`);
530
+ }
531
+ return data.value;
532
+ }
533
+ };
534
+ var DiscordChannel = class {
535
+ adapter;
536
+ convex;
537
+ config;
538
+ threadMap = /* @__PURE__ */ new Map();
539
+ logger;
540
+ processingMessages = /* @__PURE__ */ new Set();
541
+ isRunning = false;
542
+ constructor(config) {
543
+ this.config = config;
544
+ this.adapter = new DiscordAdapter();
545
+ this.convex = new ConvexHttpApi(config.convexUrl);
546
+ this.logger = createLogger(config.logLevel);
547
+ }
548
+ /**
549
+ * Start the Discord channel bot.
550
+ * Connects to Discord and begins listening for messages.
551
+ */
552
+ async start() {
553
+ if (this.isRunning) {
554
+ this.logger.warn("Discord channel is already running");
555
+ return;
556
+ }
557
+ this.logger.info("Starting Discord channel...");
558
+ this.logger.info(`Agent ID: ${this.config.agentId}`);
559
+ this.logger.info(`Convex URL: ${this.config.convexUrl}`);
560
+ try {
561
+ const agent = await this.convex.query("agents:get", { id: this.config.agentId });
562
+ if (!agent) {
563
+ throw new Error(
564
+ `Agent "${this.config.agentId}" not found in Convex. Create it first with: agentforge agents create`
565
+ );
566
+ }
567
+ const agentData = agent;
568
+ this.logger.info(`Agent: ${agentData.name} (${agentData.model} via ${agentData.provider})`);
569
+ } catch (error) {
570
+ if (error instanceof Error && error.message.includes("not found")) {
571
+ throw error;
572
+ }
573
+ this.logger.warn("Could not verify agent (Convex may be unreachable). Continuing...");
574
+ }
575
+ this.adapter.on(this.handleEvent.bind(this));
576
+ const channelConfig = {
577
+ id: `discord-${this.config.agentId}`,
578
+ platform: "discord",
579
+ orgId: "default",
580
+ agentId: this.config.agentId,
581
+ enabled: true,
582
+ credentials: {
583
+ botToken: this.config.botToken,
584
+ ...this.config.clientId ? { clientId: this.config.clientId } : {},
585
+ ...this.config.guildId ? { guildId: this.config.guildId } : {}
586
+ },
587
+ settings: {
588
+ mentionOnly: this.config.mentionOnly ?? false,
589
+ respondToDMs: this.config.respondToDMs ?? true,
590
+ registerCommands: true
591
+ },
592
+ autoReconnect: true,
593
+ reconnectIntervalMs: 5e3,
594
+ maxReconnectAttempts: 20
595
+ };
596
+ await this.adapter.start(channelConfig);
597
+ this.isRunning = true;
598
+ this.logger.info("Discord channel started successfully!");
599
+ this.logger.info("Bot is listening for messages...");
600
+ this.logger.info("Press Ctrl+C to stop.");
601
+ }
602
+ /**
603
+ * Stop the Discord channel bot.
604
+ */
605
+ async stop() {
606
+ if (!this.isRunning) return;
607
+ this.logger.info("Stopping Discord channel...");
608
+ await this.adapter.stop();
609
+ this.isRunning = false;
610
+ this.threadMap.clear();
611
+ this.processingMessages.clear();
612
+ this.logger.info("Discord channel stopped.");
613
+ }
614
+ /**
615
+ * Get the current thread map (for debugging).
616
+ */
617
+ getThreadMap() {
618
+ return this.threadMap;
619
+ }
620
+ /**
621
+ * Get the underlying DiscordAdapter instance.
622
+ */
623
+ getAdapter() {
624
+ return this.adapter;
625
+ }
626
+ /**
627
+ * Check if the channel is running.
628
+ */
629
+ get running() {
630
+ return this.isRunning;
631
+ }
632
+ // ----- Internal: Event Handling -----
633
+ async handleEvent(event) {
634
+ switch (event.type) {
635
+ case "message":
636
+ await this.handleInboundMessage(event.data);
637
+ break;
638
+ case "connection_state":
639
+ this.logger.debug("Connection state:", event.data.state);
640
+ break;
641
+ case "error":
642
+ this.logger.error("Adapter error:", event.data.error.message);
643
+ break;
644
+ default:
645
+ this.logger.debug("Unhandled event type:", event.type);
646
+ }
647
+ }
648
+ async handleInboundMessage(message) {
649
+ if (!message.text?.trim()) {
650
+ this.logger.debug("Skipping empty message");
651
+ return;
652
+ }
653
+ const dedupKey = `${message.chatId}:${message.platformMessageId}`;
654
+ if (this.processingMessages.has(dedupKey)) {
655
+ this.logger.debug("Skipping duplicate message:", dedupKey);
656
+ return;
657
+ }
658
+ this.processingMessages.add(dedupKey);
659
+ const senderName = message.sender.displayName ?? message.sender.username ?? "Unknown";
660
+ this.logger.info(`Message from ${senderName} (channel ${message.chatId}): ${message.text}`);
661
+ try {
662
+ if (message.text.startsWith("/start")) {
663
+ await this.handleStartCommand(message);
664
+ return;
665
+ }
666
+ if (message.text.startsWith("/new")) {
667
+ this.threadMap.delete(message.chatId);
668
+ await this.adapter.sendMessage({
669
+ chatId: message.chatId,
670
+ text: "New conversation started. Send me a message!"
671
+ });
672
+ return;
673
+ }
674
+ if (message.text.startsWith("/help")) {
675
+ await this.handleHelpCommand(message);
676
+ return;
677
+ }
678
+ await this.routeToAgent(message);
679
+ } catch (error) {
680
+ this.logger.error("Error handling message:", error);
681
+ try {
682
+ await this.adapter.sendMessage({
683
+ chatId: message.chatId,
684
+ text: "Sorry, I encountered an error processing your message. Please try again."
685
+ });
686
+ } catch {
687
+ this.logger.error("Failed to send error message to user");
688
+ }
689
+ } finally {
690
+ setTimeout(() => {
691
+ this.processingMessages.delete(dedupKey);
692
+ }, 3e4);
693
+ }
694
+ }
695
+ // ----- Internal: Agent Routing -----
696
+ /**
697
+ * Route a message through the AgentForge chat pipeline:
698
+ * 1. Get or create a Convex thread for this Discord channel
699
+ * 2. Call chat.sendMessage action
700
+ * 3. Send the agent response back to Discord
701
+ */
702
+ async routeToAgent(message) {
703
+ await this.adapter.sendTypingIndicator(message.chatId);
704
+ const threadId = await this.getOrCreateThread(
705
+ message.chatId,
706
+ message.sender.displayName
707
+ );
708
+ const userId = this.config.userId ?? `discord:${message.sender.platformUserId}`;
709
+ this.logger.debug(`Sending to agent ${this.config.agentId}, thread ${threadId}`);
710
+ const result = await this.convex.action("chat:sendMessage", {
711
+ agentId: this.config.agentId,
712
+ threadId,
713
+ content: message.text,
714
+ userId
715
+ });
716
+ if (result?.response) {
717
+ const chunks = this.splitMessage(result.response, 2e3);
718
+ for (const chunk of chunks) {
719
+ await this.adapter.sendMessage({
720
+ chatId: message.chatId,
721
+ text: chunk,
722
+ replyToMessageId: chunks.length === 1 ? message.platformMessageId : void 0
723
+ });
724
+ }
725
+ if (result.usage) {
726
+ this.logger.debug(`Tokens used: ${result.usage.totalTokens}`);
727
+ }
728
+ } else {
729
+ await this.adapter.sendMessage({
730
+ chatId: message.chatId,
731
+ text: "I received your message but couldn't generate a response. Please try again."
732
+ });
733
+ }
734
+ }
735
+ // ----- Internal: Thread Management -----
736
+ /**
737
+ * Get or create a Convex thread for a Discord channel.
738
+ * Threads are cached in memory and created lazily.
739
+ */
740
+ async getOrCreateThread(channelId, senderName) {
741
+ const cached = this.threadMap.get(channelId);
742
+ if (cached) return cached;
743
+ const threadName = senderName ? `Discord: ${senderName}` : `Discord Channel ${channelId}`;
744
+ const userId = this.config.userId ?? `discord:${channelId}`;
745
+ const threadId = await this.convex.mutation("chat:createThread", {
746
+ agentId: this.config.agentId,
747
+ name: threadName,
748
+ userId
749
+ });
750
+ this.threadMap.set(channelId, threadId);
751
+ this.logger.info(`Created new thread ${threadId} for channel ${channelId}`);
752
+ try {
753
+ await this.convex.mutation("logs:add", {
754
+ level: "info",
755
+ source: "discord",
756
+ message: `New Discord conversation started by ${senderName ?? channelId}`,
757
+ metadata: { channelId, threadId, agentId: this.config.agentId },
758
+ userId
759
+ });
760
+ } catch {
761
+ }
762
+ return threadId;
763
+ }
764
+ // ----- Internal: Command Handlers -----
765
+ async handleStartCommand(message) {
766
+ this.threadMap.delete(message.chatId);
767
+ let agentName = "AI Assistant";
768
+ try {
769
+ const agent = await this.convex.query("agents:get", { id: this.config.agentId });
770
+ if (agent) {
771
+ agentName = agent.name;
772
+ }
773
+ } catch {
774
+ }
775
+ await this.adapter.sendMessage({
776
+ chatId: message.chatId,
777
+ text: `Welcome! I'm **${agentName}**, powered by AgentForge.
778
+
779
+ Send me a message and I'll respond using AI.
780
+
781
+ **Commands:**
782
+ /new \u2014 Start a new conversation
783
+ /help \u2014 Show help information`
784
+ });
785
+ }
786
+ async handleHelpCommand(message) {
787
+ await this.adapter.sendMessage({
788
+ chatId: message.chatId,
789
+ text: `**AgentForge Discord Bot**
790
+
791
+ Just send me a message and I'll respond using AI.
792
+
793
+ **Commands:**
794
+ /start \u2014 Reset and show welcome message
795
+ /new \u2014 Start a fresh conversation thread
796
+ /help \u2014 Show this help message
797
+
798
+ *Powered by AgentForge \u2014 agentforge.dev*`
799
+ });
800
+ }
801
+ // ----- Internal: Utilities -----
802
+ /**
803
+ * Split a long message into chunks that fit Discord's 2000 char limit.
804
+ */
805
+ splitMessage(text, maxLength) {
806
+ if (text.length <= maxLength) return [text];
807
+ const chunks = [];
808
+ let remaining = text;
809
+ while (remaining.length > 0) {
810
+ if (remaining.length <= maxLength) {
811
+ chunks.push(remaining);
812
+ break;
813
+ }
814
+ let splitIdx = remaining.lastIndexOf("\n\n", maxLength);
815
+ if (splitIdx === -1 || splitIdx < maxLength / 2) {
816
+ splitIdx = remaining.lastIndexOf("\n", maxLength);
817
+ }
818
+ if (splitIdx === -1 || splitIdx < maxLength / 2) {
819
+ splitIdx = remaining.lastIndexOf(" ", maxLength);
820
+ }
821
+ if (splitIdx === -1 || splitIdx < maxLength / 2) {
822
+ splitIdx = maxLength;
823
+ }
824
+ chunks.push(remaining.substring(0, splitIdx));
825
+ remaining = remaining.substring(splitIdx).trimStart();
826
+ }
827
+ return chunks;
828
+ }
829
+ };
830
+ async function startDiscordChannel(overrides = {}) {
831
+ const botToken = overrides.botToken ?? process.env.DISCORD_BOT_TOKEN;
832
+ const convexUrl = overrides.convexUrl ?? process.env.CONVEX_URL;
833
+ const agentId = overrides.agentId ?? process.env.AGENTFORGE_AGENT_ID;
834
+ if (!botToken) {
835
+ throw new Error(
836
+ "DISCORD_BOT_TOKEN is required. Set it in your .env file or pass it as botToken in the config."
837
+ );
838
+ }
839
+ if (!convexUrl) {
840
+ throw new Error(
841
+ "CONVEX_URL is required. Set it in your .env file or pass it as convexUrl in the config."
842
+ );
843
+ }
844
+ if (!agentId) {
845
+ throw new Error(
846
+ "Agent ID is required. Pass it as agentId in the config or set AGENTFORGE_AGENT_ID env var."
847
+ );
848
+ }
849
+ const channel = new DiscordChannel({
850
+ botToken,
851
+ convexUrl,
852
+ agentId,
853
+ clientId: overrides.clientId ?? process.env.DISCORD_CLIENT_ID,
854
+ guildId: overrides.guildId ?? process.env.DISCORD_GUILD_ID,
855
+ mentionOnly: overrides.mentionOnly,
856
+ respondToDMs: overrides.respondToDMs,
857
+ userId: overrides.userId,
858
+ logLevel: overrides.logLevel
859
+ });
860
+ const shutdown = async () => {
861
+ console.log("\nShutting down Discord channel...");
862
+ await channel.stop();
863
+ process.exit(0);
864
+ };
865
+ process.on("SIGINT", shutdown);
866
+ process.on("SIGTERM", shutdown);
867
+ await channel.start();
868
+ return channel;
869
+ }
870
+ export {
871
+ DiscordAdapter,
872
+ DiscordChannel,
873
+ startDiscordChannel
874
+ };
875
+ //# sourceMappingURL=index.js.map