b9bfee9c5b
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.
244 lines
7.3 KiB
TypeScript
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(),
|
|
});
|
|
}
|
|
}
|