Add #rss command family with LLM tags and summaries (v0.20.0)
CI / build + unit (pull_request) Successful in 1m15s
CI / build + unit (pull_request) Successful in 1m15s
#rss subcommands on the command channel: help / add <url> <name> / remove <name> / tags / sources / get <name|#tag>. Sources are stored in a new rss_sources table in the existing MESH_DB_PATH file (second node:sqlite connection, case-insensitive names); feeds parsed with fast-xml-parser (RSS 2.0 + Atom); AI tags and headline summaries via injected LlmService.chat() with bounded maxTokens (50/200). CommandService gains prefix registration so one handler owns the whole #rss family while #ping stays an exact match; wired in CommandModule's factory — still no constructor-level cycle, no forwardRef. One dead feed does not sink a tag summary.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
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,
|
||||
@@ -7,6 +12,7 @@ import {
|
||||
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';
|
||||
@@ -134,6 +140,71 @@ describe('CommandService.onRx', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 <url> <name>',
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user