Files
flynn/src/channels/discord/adapter.ts
T
William Valentin b9bfee9c5b feat: add outbound attachment support with media.send tool
Introduces OutboundAttachment type on OutboundMessage, an
OutboundAttachmentCollector (push/drain pattern), and a media.send
tool that queues files for outbound delivery. Each channel adapter
(Telegram, Discord, Slack, WhatsApp) sends attachments after the
text reply. Includes 15 tests for collector and tool.
2026-02-07 09:09:00 -08:00

244 lines
7.3 KiB
TypeScript

/**
* 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<string, string> = {
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<void> {
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<void>((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<void> {
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<void> {
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<string, unknown>) => Promise<unknown> };
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(),
});
}
}