import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { LlmService } from '../llm/llm.service.js'; import { RssService } from '../rss/rss.service.js'; import { startBridge, textPacketFrame, waitFor, outboundPackets, KAMIL_NUM, } from '../mesh/fake-bridge.js'; import { BROADCAST_ADDR } from '../mesh/codec.js'; import type { MeshService } from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { CommandService } from './command.service.js'; function makeService( sendText?: ReturnType, respond?: ReturnType, ): [CommandService, MeshService] { const mesh = { sendText: sendText ?? vi.fn(), respond: respond ?? vi.fn().mockResolvedValue({ id: 1 }), } as unknown as MeshService; return [new CommandService(mesh), mesh]; } beforeEach(() => { delete process.env.COMMAND_CHANNEL; // default channel 2 under test }); describe('CommandService.onRx', () => { it('replies #ping via respond() — threaded quote of the trigger packet, no direct sendText', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, '#ping', 42, 7); expect(mesh.respond).toHaveBeenCalledWith(7, '#pong!'); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, '#ping', 42); // from present but no id — defensive path expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2); }); it('falls back to a broadcast sendText on COMMAND_CHANNEL when the trigger has no packet id (no sender)', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, '#ping'); // no from, no id — defensive path expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).toHaveBeenCalledWith( BROADCAST_ADDR, '#pong!', undefined, 2, ); }); it('ignores the same text on other channels (ch0 and ch3)', async () => { const [svc, mesh] = makeService(); await svc.onRx(0, '#ping', 42); await svc.onRx(3, '#ping', 42); expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('ignores non-command text on the command channel', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, 'hello there', 42); await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('matches command names exactly and case-sensitively', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, '#Ping', 42); await svc.onRx(2, '#ping!', 42); // extra char — no partial matches expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('trims surrounding whitespace before matching', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, ' #ping ', 42, 9); expect(mesh.respond).toHaveBeenCalledWith(9, '#pong!'); }); it('honors the COMMAND_CHANNEL env override', async () => { process.env.COMMAND_CHANNEL = '5'; const [svc, mesh] = makeService(); await svc.onRx(2, '#ping', 42); // old default no longer triggers expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); await svc.onRx(5, '#ping', 42, 8); expect(mesh.respond).toHaveBeenCalledWith(8, '#pong!'); }); it('sends nothing when a handler returns void', async () => { const [svc, mesh] = makeService(); svc.register('silent', () => undefined); await svc.onRx(2, '#silent', 42); expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('is extensible: a second registered command dispatches through the same path', async () => { const [svc, mesh] = makeService(); svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`); await svc.onRx(2, '#status', 99, 5); expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99'); }); it('sends each line of an array reply as its own threaded respond() to the same packet id', async () => { const [svc, mesh] = makeService(); svc.register('multi', () => ['one: first item', 'two: second item']); await svc.onRx(2, '#multi', 42, 9); expect(mesh.respond).toHaveBeenCalledTimes(2); expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']); expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => { const [svc, mesh] = makeService(); svc.register('multi', () => ['one: first item', 'two: second item']); await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender expect(mesh.sendText).toHaveBeenCalledTimes(2); expect(mesh.sendText.mock.calls[0]).toEqual([ 42, 'one: first item', undefined, 2, ]); expect(mesh.respond).not.toHaveBeenCalled(); }); it('swallows a failing handler (a bad command must not crash the app)', async () => { const [svc, mesh] = makeService(); svc.register('boom', () => { throw new Error('kaboom'); }); await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined(); expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('swallows a failing respond (unknown id / bridge down) without throwing', async () => { const [svc] = makeService( undefined, vi.fn().mockRejectedValue(new Error('no message with id 7')), ); await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined(); }); it('swallows a failing send (mesh down) without throwing', async () => { const [svc] = makeService( vi.fn().mockRejectedValue(new Error('no bridge')), ); await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path }); }); describe('#rss prefix dispatch', () => { let dir: string | undefined; const makeRss = (): RssService => { dir = mkdtempSync(join(tmpdir(), 'mesh-bot-cmd-rss-')); return new RssService( { chat: vi.fn() } as unknown as LlmService, new DatabaseSync(join(dir, 'test.db')), ); }; afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = undefined; }); it('routes #rss subcommands through respond() like any command reply', async () => { const mesh = { sendText: vi.fn(), respond: vi.fn().mockResolvedValue({ id: 1 }), } as unknown as MeshService; const svc = new CommandService(mesh, makeRss()); await svc.onRx(2, '#rss sources', 42, 9); expect(mesh.respond).toHaveBeenCalledWith(9, 'no sources yet'); expect(mesh.sendText).not.toHaveBeenCalled(); }); it('routes bare #rss to the help text via respond()', async () => { const mesh = { sendText: vi.fn(), respond: vi.fn().mockResolvedValue({ id: 1 }), } as unknown as MeshService; const svc = new CommandService(mesh, makeRss()); await svc.onRx(2, '#rss', 42, 9); expect(mesh.respond).toHaveBeenCalledTimes(1); expect(String(mesh.respond.mock.calls[0][1])).toContain( '#rss add ', ); }); it('leaves exact commands untouched and does not partial-match prefix names', async () => { const mesh = { sendText: vi.fn(), respond: vi.fn().mockResolvedValue({ id: 1 }), } as unknown as MeshService; const svc = new CommandService(mesh, makeRss()); await svc.onRx(2, '#ping', 42, 3); expect(mesh.respond).toHaveBeenCalledWith(3, '#pong!'); await svc.onRx(2, '#rssfoo', 42, 4); // starts with 'rss' but is not the family — no partial prefix match expect(mesh.respond).toHaveBeenCalledTimes(1); }); it('does nothing for #rss when no RssService was wired (legacy construction)', async () => { const [svc, mesh] = makeService(); // rss omitted await svc.onRx(2, '#rss sources', 42, 9); expect(mesh.respond).not.toHaveBeenCalled(); expect(mesh.sendText).not.toHaveBeenCalled(); }); }); describe('MeshService wiring (event-driven trigger)', () => { it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new RealMeshService(); try { // wire the responder exactly like command.module.ts does in production svc.attachCommands(new CommandService(svc)); svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); const out: Uint8Array[] = []; bridge.sockets[0].on('data', (c) => out.push(c)); // rx DM from KAMIL to node 42 on channel 2 (packet id 1) bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2)); // rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0]) await waitFor(() => svc.messages().length === 2); expect(svc.messages()[0]).toMatchObject({ dir: 'tx', to: KAMIL_NUM, text: '#pong!', channel: 2, }); // wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote) await waitFor(() => outboundPackets(out).length >= 1); const pkt = outboundPackets(out)[0]!; expect(pkt.to).toBe(KAMIL_NUM); expect(pkt.channel).toBe(2); const data = pkt.payloadVariant?.case === 'decoded' ? pkt.payloadVariant.value : undefined; expect(data?.replyId).toBe(1); } finally { svc.onModuleDestroy(); bridge.close(); } }); it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new RealMeshService(); try { svc.attachCommands(new CommandService(svc)); svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); const out: Uint8Array[] = []; bridge.sockets[0].on('data', (c) => out.push(c)); // rx broadcast from KAMIL on channel 2 (packet id 1) bridge.sockets[0].write( textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2), ); await waitFor(() => svc.messages().length === 2); expect(svc.messages()[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR, // broadcast original → broadcast reply text: '#pong!', channel: 2, }); await waitFor(() => outboundPackets(out).length >= 1); const pkt = outboundPackets(out)[0]!; expect(pkt.to).toBe(BROADCAST_ADDR); expect(pkt.channel).toBe(2); const data = pkt.payloadVariant?.case === 'decoded' ? pkt.payloadVariant.value : undefined; expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1) } finally { svc.onModuleDestroy(); bridge.close(); } }); it('does not answer #ping arriving on a non-command channel', async () => { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new RealMeshService(); try { svc.attachCommands(new CommandService(svc)); svc.onModuleInit(); await waitFor( () => bridge.sockets.length === 1 && svc.status().connected, ); bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3)); await waitFor(() => svc.messages().length === 1); // only the rx record — no reply expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: '#ping', channel: 3, }); } finally { svc.onModuleDestroy(); bridge.close(); } }); });