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,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<string, ((...args: unknown[]) => 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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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<typeof vi.fn>).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!');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordAdapter, type DiscordAdapterConfig } from './adapter.js';
|
||||
Reference in New Issue
Block a user