From 00db84f6a1fb3a08c73fab67384ef2ff42505780 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 6 Feb 2026 14:24:11 -0800 Subject: [PATCH] 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 --- src/channels/discord/adapter.test.ts | 425 +++++++++++++++++++++++++++ src/channels/discord/adapter.ts | 215 ++++++++++++++ src/channels/discord/index.ts | 1 + 3 files changed, 641 insertions(+) create mode 100644 src/channels/discord/adapter.test.ts create mode 100644 src/channels/discord/adapter.ts create mode 100644 src/channels/discord/index.ts diff --git a/src/channels/discord/adapter.test.ts b/src/channels/discord/adapter.test.ts new file mode 100644 index 0000000..131ae41 --- /dev/null +++ b/src/channels/discord/adapter.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mock discord.js before importing adapter ────────────────────── + +/** Map of event name → handler function for the mock client. */ +type HandlerMap = Map void)[]>; + +const mockChannelSend = vi.fn(); + +/** Create a fresh mock client instance. */ +function createMockClient() { + const handlers: HandlerMap = new Map(); + return { + _handlers: handlers, + user: null as { id: string; tag: string } | null, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event)!.push(handler); + }), + login: vi.fn(async (_token: string) => { + // Set user info after login + mockClient.user = { id: '123456789', tag: 'TestBot#0001' }; + // Trigger ready event asynchronously + setTimeout(() => { + const readyHandlers = handlers.get('ready') ?? []; + for (const h of readyHandlers) h(); + }, 0); + }), + destroy: vi.fn(), + channels: { + fetch: vi.fn(async (_id: string) => ({ + send: mockChannelSend, + })), + }, + }; +} + +let mockClient = createMockClient(); + +vi.mock('discord.js', () => ({ + Client: vi.fn().mockImplementation(() => mockClient), + GatewayIntentBits: { + Guilds: 1, + GuildMessages: 2, + MessageContent: 4, + DirectMessages: 8, + }, + Events: { + ClientReady: 'ready', + MessageCreate: 'messageCreate', + }, +})); + +import { DiscordAdapter, type DiscordAdapterConfig } from './adapter.js'; +import type { InboundMessage } from '../types.js'; + +const baseConfig: DiscordAdapterConfig = { + botToken: 'test-discord-token', +}; + +/** Helper: emit a mock event on the client. */ +function emitEvent(eventName: string, ...args: unknown[]) { + const eventHandlers = mockClient._handlers.get(eventName) ?? []; + for (const handler of eventHandlers) { + handler(...args); + } +} + +/** Helper: create a mock Discord message for guild channels. */ +function createGuildMessage(overrides: Record = {}) { + return { + id: 'msg-1', + content: 'Hello Flynn', + author: { bot: false, username: 'TestUser' }, + guild: { id: 'guild-1' }, + channelId: 'channel-1', + mentions: { + has: vi.fn().mockReturnValue(false), + }, + ...overrides, + }; +} + +/** Helper: create a mock Discord DM message. */ +function createDMMessage(overrides: Record = {}) { + return { + id: 'dm-1', + content: 'Hello from DM', + author: { bot: false, username: 'DMUser' }, + guild: null, + channelId: 'dm-channel-1', + mentions: { + has: vi.fn().mockReturnValue(false), + }, + ...overrides, + }; +} + +describe('DiscordAdapter', () => { + let adapter: DiscordAdapter; + + beforeEach(async () => { + vi.clearAllMocks(); + mockClient = createMockClient(); + // Re-wire the Client mock to return the fresh mockClient + const { Client } = vi.mocked(await import('discord.js')); + (Client as unknown as ReturnType).mockImplementation(() => mockClient); + adapter = new DiscordAdapter(baseConfig); + }); + + // ── Basic properties ────────────────────────────────────────── + + it('has name "discord"', () => { + expect(adapter.name).toBe('discord'); + }); + + it('starts as disconnected', () => { + expect(adapter.status).toBe('disconnected'); + }); + + // ── connect / disconnect ────────────────────────────────────── + + it('connect creates client and sets connected status', async () => { + await adapter.connect(); + + expect(adapter.status).toBe('connected'); + const { Client } = await import('discord.js'); + expect(Client).toHaveBeenCalledWith({ + intents: [1, 2, 4, 8], + }); + expect(mockClient.login).toHaveBeenCalledWith('test-discord-token'); + }); + + it('connect registers messageCreate handler', async () => { + await adapter.connect(); + + // Should have registered handlers for 'ready' and 'messageCreate' + const eventNames = Array.from(mockClient._handlers.keys()); + expect(eventNames).toContain('ready'); + expect(eventNames).toContain('messageCreate'); + }); + + it('disconnect destroys client and sets disconnected', async () => { + await adapter.connect(); + expect(adapter.status).toBe('connected'); + + await adapter.disconnect(); + expect(mockClient.destroy).toHaveBeenCalledTimes(1); + expect(adapter.status).toBe('disconnected'); + }); + + it('disconnect is safe when not connected', async () => { + await adapter.disconnect(); + expect(adapter.status).toBe('disconnected'); + // No client to destroy — should not throw + }); + + // ── send ────────────────────────────────────────────────────── + + it('send throws when not connected', async () => { + await expect(adapter.send('channel-1', { text: 'hello' })).rejects.toThrow( + 'Discord adapter not connected', + ); + }); + + it('send delivers a short message', async () => { + await adapter.connect(); + + await adapter.send('channel-1', { text: 'Hello there' }); + + expect(mockClient.channels.fetch).toHaveBeenCalledWith('channel-1'); + expect(mockChannelSend).toHaveBeenCalledTimes(1); + expect(mockChannelSend).toHaveBeenCalledWith('Hello there'); + }); + + it('send chunks long messages (>2000 chars)', async () => { + await adapter.connect(); + + // Create a message longer than 2000 chars — two halves joined by a newline + const half = 'A'.repeat(1500); + const longMessage = `${half}\n${'B'.repeat(1500)}`; + + await adapter.send('channel-1', { text: longMessage }); + + // Should have been split into at least 2 chunks + expect(mockChannelSend.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + // ── onMessage / inbound handling ────────────────────────────── + + it('inbound message from guild with mention triggers handler', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createGuildMessage({ + content: '<@123456789> Hello Flynn', + mentions: { has: vi.fn().mockReturnValue(true) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.channel).toBe('discord'); + expect(msg.senderId).toBe('channel-1'); + expect(msg.senderName).toBe('TestUser'); + expect(msg.text).toBe('Hello Flynn'); + }); + + it('inbound message from DM triggers handler without mention', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createDMMessage(); + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.channel).toBe('discord'); + expect(msg.senderId).toBe('dm-channel-1'); + expect(msg.senderName).toBe('DMUser'); + expect(msg.text).toBe('Hello from DM'); + }); + + it('ignores bot messages (author.bot = true)', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createDMMessage({ + author: { bot: true, username: 'SomeBot' }, + }); + + emitEvent('messageCreate', message); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores messages in disallowed guilds', async () => { + const restrictedAdapter = new DiscordAdapter({ + ...baseConfig, + allowedGuildIds: ['guild-allowed'], + }); + const handler = vi.fn(); + restrictedAdapter.onMessage(handler); + + await restrictedAdapter.connect(); + + const message = createGuildMessage({ + guild: { id: 'guild-not-allowed' }, + mentions: { has: vi.fn().mockReturnValue(true) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores messages in disallowed channels', async () => { + const restrictedAdapter = new DiscordAdapter({ + ...baseConfig, + allowedChannelIds: ['channel-allowed'], + requireMention: false, + }); + const handler = vi.fn(); + restrictedAdapter.onMessage(handler); + + await restrictedAdapter.connect(); + + const message = createGuildMessage({ + channelId: 'channel-not-allowed', + }); + + emitEvent('messageCreate', message); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('strips bot mention from message text', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createGuildMessage({ + content: '<@123456789> What is the weather?', + mentions: { has: vi.fn().mockReturnValue(true) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('What is the weather?'); + }); + + it('!reset command delivers reset metadata', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createDMMessage({ + content: '!reset', + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('!reset'); + expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); + + it('guild message without mention is ignored when requireMention is true', async () => { + // Default config has requireMention undefined (defaults to true) + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createGuildMessage({ + mentions: { has: vi.fn().mockReturnValue(false) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).not.toHaveBeenCalled(); + }); + + // ── Additional edge cases ───────────────────────────────────── + + it('guild message without mention is accepted when requireMention is false', async () => { + const noMentionAdapter = new DiscordAdapter({ + ...baseConfig, + requireMention: false, + }); + const handler = vi.fn(); + noMentionAdapter.onMessage(handler); + + await noMentionAdapter.connect(); + + const message = createGuildMessage({ + mentions: { has: vi.fn().mockReturnValue(false) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no message handler is registered', async () => { + // Don't call onMessage — no handler registered + await adapter.connect(); + + const message = createDMMessage(); + // Should not throw + emitEvent('messageCreate', message); + }); + + it('"reset" text (without !) delivers reset metadata in DMs', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createDMMessage({ + content: 'reset', + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); + + it('strips mention and recognizes reset command after mention', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + const message = createGuildMessage({ + content: '<@123456789> reset', + mentions: { has: vi.fn().mockReturnValue(true) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); + + it('allowed guild messages with mention pass through', async () => { + const restrictedAdapter = new DiscordAdapter({ + ...baseConfig, + allowedGuildIds: ['guild-1'], + allowedChannelIds: ['channel-1'], + }); + const handler = vi.fn(); + restrictedAdapter.onMessage(handler); + + await restrictedAdapter.connect(); + + const message = createGuildMessage({ + content: '<@123456789> Hello!', + guild: { id: 'guild-1' }, + channelId: 'channel-1', + mentions: { has: vi.fn().mockReturnValue(true) }, + }); + + emitEvent('messageCreate', message); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello!'); + }); +}); diff --git a/src/channels/discord/adapter.ts b/src/channels/discord/adapter.ts new file mode 100644 index 0000000..aa3881d --- /dev/null +++ b/src/channels/discord/adapter.ts @@ -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 { + 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) => Promise }; + + 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(), + }); + } +} diff --git a/src/channels/discord/index.ts b/src/channels/discord/index.ts new file mode 100644 index 0000000..8149715 --- /dev/null +++ b/src/channels/discord/index.ts @@ -0,0 +1 @@ +export { DiscordAdapter, type DiscordAdapterConfig } from './adapter.js';