feat: add Discord channel adapter (Phase 3a)
Implement ChannelAdapter for Discord using discord.js: - Bot mention filtering and mention stripping - Guild and channel allowlist filtering - Message chunking at 2000 chars - Reset command detection (!reset / reset in DMs) - 22 tests covering all behaviors
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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 } from 'discord.js';
|
||||
import type { Message as DiscordMessage } from 'discord.js';
|
||||
|
||||
import type {
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect Discord's 2000 char limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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) => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
// ── 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,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user