/** * Discord channel adapter. * * Implements the ChannelAdapter interface using discord.js v14. * Supports guild channels (with optional mention requirement) and DMs. * Messages are chunked at Discord's 2000-char limit. */ import { Client, GatewayIntentBits, Events, AttachmentBuilder } from 'discord.js'; import type { Message as DiscordMessage } from 'discord.js'; import type { Attachment, InboundMessage, OutboundMessage, OutboundAttachment, ChannelAdapter, ChannelStatus, } from '../types.js'; import { splitMessage } from '../utils.js'; /** Configuration for the Discord channel adapter. */ export interface DiscordAdapterConfig { botToken: string; /** Guild IDs to respond in. Empty = all guilds. */ allowedGuildIds?: string[]; /** Channel IDs to respond in. Empty = all channels. */ allowedChannelIds?: string[]; /** Whether to require mention to respond in guild channels (default: true). DMs always respond. */ requireMention?: boolean; } /** * Discord channel adapter backed by discord.js. * * Handles guild/channel filtering, optional mention requirement, * DM support, and message chunking for Discord's 2000-char limit. */ export class DiscordAdapter implements ChannelAdapter { readonly name = 'discord'; private _status: ChannelStatus = 'disconnected'; private client: Client | null = null; private messageHandler?: (msg: InboundMessage) => void; private config: DiscordAdapterConfig; get status(): ChannelStatus { return this._status; } constructor(config: DiscordAdapterConfig) { this.config = config; } /** Infer MIME type from URL if contentType is not provided. */ private _inferMimeTypeFromUrl(url: string): string | null { const ext = url.split('.').pop()?.toLowerCase(); const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', }; return mimeTypes[ext || ''] || null; } /** Register the inbound message handler. Called by the registry before connect(). */ onMessage(handler: (msg: InboundMessage) => void): void { this.messageHandler = handler; } /** Create the discord.js client, wire up event handlers, and log in. */ async connect(): Promise { this._status = 'connecting'; this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], }); // ── Ready handler — resolve connect() when the bot is online ── const readyPromise = new Promise((resolve) => { this.client!.on(Events.ClientReady, () => { console.log(`Discord bot ready as ${this.client!.user?.tag}`); this._status = 'connected'; resolve(); }); }); // ── Message handler — route inbound messages ── this.client.on(Events.MessageCreate, (message: DiscordMessage) => { this.handleMessage(message); }); // Log in and wait for the ready event await this.client.login(this.config.botToken); await readyPromise; } /** Stop the client and clean up. */ async disconnect(): Promise { if (this.client) { this.client.destroy(); this.client = null; } this._status = 'disconnected'; } /** Send an outbound message, automatically chunking if it exceeds Discord's 2000-char limit. */ async send(peerId: string, message: OutboundMessage): Promise { if (!this.client) throw new Error('Discord adapter not connected'); const channel = await this.client.channels.fetch(peerId); if (!channel || !('send' in channel)) { throw new Error(`Channel ${peerId} not found or is not a text channel`); } const text = message.text; const sendable = channel as { send: (content: string | Record) => Promise }; if (text.length <= 2000) { await sendable.send(text); } else { const chunks = splitMessage(text, 2000); for (const chunk of chunks) { await sendable.send(chunk); } } // Send outbound attachments after text if (message.attachments && message.attachments.length > 0) { const files = message.attachments .filter((a) => a.data || a.url) .map((a) => this.buildDiscordAttachment(a)); if (files.length > 0) { await sendable.send({ files }); } } } /** Build a discord.js AttachmentBuilder from an OutboundAttachment. */ private buildDiscordAttachment(attachment: OutboundAttachment): AttachmentBuilder { if (attachment.data) { return new AttachmentBuilder(Buffer.from(attachment.data, 'base64'), { name: attachment.filename ?? 'attachment', }); } // URL-based attachment return new AttachmentBuilder(attachment.url!, { name: attachment.filename ?? 'attachment', }); } /** Internal: process an inbound Discord message. */ private handleMessage(message: DiscordMessage): void { if (!this.messageHandler) return; // Ignore bot messages if (message.author.bot) return; const isDM = !message.guild; // ── Guild/channel filtering ── if (!isDM) { // Check allowed guild IDs if ( this.config.allowedGuildIds && this.config.allowedGuildIds.length > 0 && !this.config.allowedGuildIds.includes(message.guild!.id) ) { return; } // Check allowed channel IDs if ( this.config.allowedChannelIds && this.config.allowedChannelIds.length > 0 && !this.config.allowedChannelIds.includes(message.channelId) ) { return; } // ── Mention requirement in guild channels ── const requireMention = this.config.requireMention ?? true; if (requireMention && this.client?.user) { if (!message.mentions.has(this.client.user)) { return; } } } // Strip bot mention from the message text const text = message.content.replace(/<@!?\d+>/g, '').trim(); // ── Extract media attachments ── const attachments: Attachment[] = []; if (message.attachments && message.attachments.size > 0) { for (const attachment of message.attachments.values()) { const mimeType = attachment.contentType || this._inferMimeTypeFromUrl(attachment.url); if (mimeType && (mimeType.startsWith('image/') || mimeType.startsWith('audio/'))) { attachments.push({ mimeType, url: attachment.url, filename: attachment.name, size: attachment.size, }); } } } // ── Reset command ── if (text === '!reset' || text === 'reset') { this.messageHandler({ id: message.id, channel: 'discord', senderId: message.channelId, senderName: message.author.username, text: '!reset', timestamp: Date.now(), metadata: { isCommand: true, command: 'reset' }, }); return; } // ── Regular message ── this.messageHandler({ id: message.id, channel: 'discord', senderId: message.channelId, senderName: message.author.username, text, attachments: attachments.length > 0 ? attachments : undefined, timestamp: Date.now(), }); } }