Files
waggle-os/packages/server/tests/channels-discord.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

219 lines
7.3 KiB
TypeScript

/**
* DiscordAdapter — gateway handshake (HELLO→IDENTIFY→READY), heartbeats,
* MESSAGE_CREATE normalization, bot-echo filtering, reconnect, chunked REST
* send (CHANNELS-ARC P2). Fake ws + fetch; no network.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DiscordAdapter, DISCORD_INTENTS, DISCORD_MAX_TEXT } from '../src/local/channels/discord-adapter.js';
import type { ChannelMessage, WsLike } from '../src/local/channels/types.js';
const noopLog = { info: () => undefined, warn: () => undefined };
class FakeWs implements WsLike {
sent: Array<Record<string, unknown>> = [];
closed = false;
private handlers = new Map<string, Array<(...args: unknown[]) => void>>();
on(event: string, cb: (...args: unknown[]) => void): void {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), cb]);
}
send(data: string): void {
this.sent.push(JSON.parse(data) as Record<string, unknown>);
}
close(): void {
this.closed = true;
}
emit(event: string, ...args: unknown[]): void {
for (const cb of this.handlers.get(event) ?? []) cb(...args);
}
serverSend(frame: unknown): void {
this.emit('message', JSON.stringify(frame));
}
}
function makeAdapter(overrides: { onMessage?: (m: ChannelMessage) => Promise<void> } = {}) {
const sockets: FakeWs[] = [];
const restCalls: Array<{ url: string; init?: RequestInit }> = [];
const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => {
restCalls.push({ url: String(url), init });
if (String(url).endsWith('/gateway/bot')) {
return new Response(JSON.stringify({ url: 'wss://gateway.discord.gg' }));
}
return new Response(JSON.stringify({ id: 'sent' }), { status: 200 });
}) as typeof fetch;
const received: ChannelMessage[] = [];
const adapter = new DiscordAdapter({
botToken: 'bot-token',
onMessage: overrides.onMessage ?? (async m => { received.push(m); }),
log: noopLog,
fetchImpl,
wsFactory: url => {
void url;
const ws = new FakeWs();
sockets.push(ws);
return ws;
},
backoffCapMs: 10,
});
return { adapter, sockets, restCalls, received };
}
async function tick(ms = 10): Promise<void> {
await new Promise(r => setTimeout(r, ms));
}
/** Drive HELLO → IDENTIFY → READY on the given socket. */
function handshake(ws: FakeWs, botUserId = 'bot-1'): void {
ws.serverSend({ op: 10, d: { heartbeat_interval: 100_000 } });
ws.serverSend({ op: 0, t: 'READY', s: 1, d: { user: { id: botUserId } } });
}
let adapter: DiscordAdapter | null = null;
afterEach(async () => {
await adapter?.stop();
adapter = null;
vi.restoreAllMocks();
});
describe('DiscordAdapter gateway', () => {
it('identifies with the required intents after HELLO and reports connected after READY', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
const ws = ctx.sockets[0];
handshake(ws);
const identify = ws.sent.find(f => f.op === 2);
expect(identify).toBeDefined();
expect((identify?.d as { token: string; intents: number }).token).toBe('bot-token');
expect((identify?.d as { intents: number }).intents).toBe(DISCORD_INTENTS);
expect(adapter.getStatus().connected).toBe(true);
});
it('normalizes MESSAGE_CREATE into ChannelMessage', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
const ws = ctx.sockets[0];
handshake(ws);
ws.serverSend({
op: 0, t: 'MESSAGE_CREATE', s: 2,
d: { id: 'm1', channel_id: 'c1', content: 'hello', author: { id: 'u1', username: 'marko' } },
});
await tick();
expect(ctx.received[0]).toEqual({
platform: 'discord', chatId: 'c1', senderId: 'u1',
senderName: 'marko', text: 'hello', messageId: 'm1',
});
});
it('ignores its own messages and other bots', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
const ws = ctx.sockets[0];
handshake(ws, 'bot-1');
ws.serverSend({
op: 0, t: 'MESSAGE_CREATE', s: 2,
d: { id: 'm1', channel_id: 'c1', content: 'echo', author: { id: 'bot-1' } },
});
ws.serverSend({
op: 0, t: 'MESSAGE_CREATE', s: 3,
d: { id: 'm2', channel_id: 'c1', content: 'bot msg', author: { id: 'u9', bot: true } },
});
await tick();
expect(ctx.received).toEqual([]);
});
it('answers a server heartbeat request with the last seq', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
const ws = ctx.sockets[0];
handshake(ws);
ws.serverSend({ op: 0, t: 'MESSAGE_CREATE', s: 7, d: { id: 'x', channel_id: 'c', content: 'q', author: { id: 'u' } } });
ws.serverSend({ op: 1 });
const hb = ws.sent.filter(f => f.op === 1).pop();
expect(hb?.d).toBe(7);
});
it('reconnects with a fresh IDENTIFY on op 7 RECONNECT', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
handshake(ctx.sockets[0]);
ctx.sockets[0].serverSend({ op: 7 });
await tick(50);
expect(ctx.sockets.length).toBeGreaterThanOrEqual(2);
expect(ctx.sockets[0].closed).toBe(true);
handshake(ctx.sockets[1]);
expect(ctx.sockets[1].sent.some(f => f.op === 2)).toBe(true);
expect(adapter.getStatus().connected).toBe(true);
});
it('reconnects when the socket closes unexpectedly', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
handshake(ctx.sockets[0]);
ctx.sockets[0].emit('close');
await tick(50);
expect(ctx.sockets.length).toBeGreaterThanOrEqual(2);
});
it('stop() halts reconnection and closes the socket', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.start();
await tick();
handshake(ctx.sockets[0]);
await adapter.stop();
ctx.sockets[0].emit('close');
await tick(50);
expect(ctx.sockets.length).toBe(1);
expect(adapter.getStatus()).toMatchObject({ running: false, connected: false });
adapter = null;
});
});
describe('DiscordAdapter send', () => {
it('POSTs to the channel messages endpoint with the bot token', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.send('chan-5', 'hi there');
const call = ctx.restCalls.find(c => c.url.includes('/channels/chan-5/messages'));
expect(call).toBeDefined();
expect((call?.init?.headers as Record<string, string>).Authorization).toBe('Bot bot-token');
expect(JSON.parse(String(call?.init?.body))).toEqual({ content: 'hi there' });
});
it('chunks above the 2000-char Discord limit', async () => {
const ctx = makeAdapter();
adapter = ctx.adapter;
await adapter.send('chan-5', 'y'.repeat(DISCORD_MAX_TEXT * 2 + 5));
const sends = ctx.restCalls.filter(c => c.url.includes('/messages'));
expect(sends.length).toBeGreaterThanOrEqual(3);
});
it('surfaces REST failures', async () => {
const fetchImpl = (async () => new Response('missing access', { status: 403 })) as typeof fetch;
adapter = new DiscordAdapter({
botToken: 't', onMessage: async () => undefined, log: noopLog,
fetchImpl, wsFactory: () => new FakeWs(),
});
await expect(adapter.send('c', 'hi')).rejects.toThrow(/HTTP 403/);
});
});