diff --git a/AGENTS.md b/AGENTS.md index 5c55278..e379b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,5 +27,5 @@ - MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages. - Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`. -- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. +- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add ` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary, maxTokens 200; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 200 summary) to keep mesh replies short. - LLM client (`src/llm`): `LlmService` wraps the local OpenAI-compatible server (default `http://192.168.1.70:1111/v1`, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods: `listModels()` → `data[].id`; `chat(messages, { maxTokens?, temperature? })` → `choices[0].message.content` (non-2xx throws with status + body snippet); `summarize(text)` = chat() with system prompt "Summarize the following text concisely." Env: `LLM_API_BASE`, `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default 60000, via `AbortSignal.timeout`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfb5aa..14a2e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.20.0 — 2026-09-25 18:40 + +- New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add ` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove `, `#rss tags` (all distinct tags), `#rss sources`, `#rss get ` and `#rss get #` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`. +- Storage: new `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file (default ./data/mesh-bot.db) via a second node:sqlite connection opened by `RssModule`'s factory — no new DB file. Names are matched case-insensitively everywhere (`COLLATE NOCASE`). +- Feed parsing: new dependency `fast-xml-parser` (the only one added for this feature) — RSS 2.0 (`/<link>`) and Atom (`<entry><title>`, link as `href` attribute); CDATA titles supported; malformed input or no usable items → "not a valid feed". +- LLM: `RssService` injects `LlmService` (plain DI, one-way — `RssModule` imports `LlmModule`) and uses `chat()` directly with explicit maxTokens (50 for tags, 200 for summaries) to keep mesh replies short. Tag output is sanitized (lowercase, no #, whitespace → '-', deduped). +- Dispatch: `CommandService` gains prefix registration (`registerPrefix(name, fn)`) alongside exact matches — one handler owns the whole `#rss` family while `#ping` stays an untouched exact match; exact names win over prefixes, and a name merely starting with the prefix (e.g. `#rssfoo`) does not dispatch. Wired in `CommandModule`'s factory (`inject: [MeshService, RssService]`) — still no constructor-level cycle, no forwardRef. +- Tests: 128 → 153 specs — store CRUD against a real temp SQLite file (incl. second-connection persistence), feed parser fixtures (RSS 2.0 / Atom / malformed / empty titles), handler behavior with mocked LLM + fetch (help, add success/LLM-fail/invalid-url/duplicate/non-2xx/unparseable, remove found/missing, tags/sources empty+populated, get by name/by tag across sources/dead-feed resilience/unknown name+tag), and `#rss` prefix dispatch through respond(). + ## v0.19.0 — 2026-09-25 18:03 - New LLM client module (`src/llm`): `LlmService` speaks the OpenAI-compatible chat-completions API of the local server at `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`, no auth) using native fetch only — no new dependencies. Plain feature module (`LlmModule`), wired into `app.module.ts`; no Mesh/Schedule dependency. diff --git a/package-lock.json b/package-lock.json index 037a44f..3401e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mesh-bot", - "version": "0.11.3", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mesh-bot", - "version": "0.11.3", + "version": "0.19.0", "license": "UNLICENSED", "dependencies": { "@bufbuild/protobuf": "^2.15.0", @@ -18,6 +18,7 @@ "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^12.0.2", "@scalar/nestjs-api-reference": "^1.2.21", + "fast-xml-parser": "^5.11.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -1148,6 +1149,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.151.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", @@ -2336,6 +2349,18 @@ "node": ">=14" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3279,6 +3304,45 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3830,6 +3894,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4848,6 +4924,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5503,6 +5594,21 @@ "node": ">=4" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -6147,6 +6253,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yoctocolors": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", diff --git a/package.json b/package.json index 2e55d7d..7a5344b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.19.0", + "version": "0.20.0", "description": "", "author": "", "private": true, @@ -30,6 +30,7 @@ "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^12.0.2", "@scalar/nestjs-api-reference": "^1.2.21", + "fast-xml-parser": "^5.11.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/src/app.module.ts b/src/app.module.ts index 8bf603c..60dc0f8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { BatteryAlertModule } from './battery/battery.module.js'; import { CommandModule } from './commands/command.module.js'; import { LlmModule } from './llm/llm.module.js'; import { MeshModule } from './mesh/mesh.module.js'; +import { RssModule } from './rss/rss.module.js'; import { WeatherModule } from './weather/weather.module.js'; @Module({ @@ -12,6 +13,7 @@ import { WeatherModule } from './weather/weather.module.js'; BatteryAlertModule, CommandModule, LlmModule, + RssModule, ], }) export class AppModule {} diff --git a/src/commands/command.module.ts b/src/commands/command.module.ts index 3bf538b..cc6fc9c 100644 --- a/src/commands/command.module.ts +++ b/src/commands/command.module.ts @@ -1,20 +1,22 @@ import { Module } from '@nestjs/common'; import { MeshModule } from '../mesh/mesh.module.js'; import { MeshService } from '../mesh/mesh.service.js'; +import { RssModule } from '../rss/rss.module.js'; +import { RssService } from '../rss/rss.service.js'; import { CommandService } from './command.service.js'; @Module({ - imports: [MeshModule], // one-way only — the reverse edge is wired in the factory below, not via DI + imports: [MeshModule, RssModule], // one-way only — the MeshService reverse edge is wired in the factory below, not via DI providers: [ { provide: CommandService, - useFactory: (mesh: MeshService) => { - const commands = new CommandService(mesh); + useFactory: (mesh: MeshService, rss: RssService) => { + const commands = new CommandService(mesh, rss); // break the cycle: MeshService can't inject us (its own factory would deadlock on this edge), so attach ourselves once we exist — mesh is guaranteed created here because it's our injected dependency mesh.attachCommands(commands); return commands; }, - inject: [MeshService], + inject: [MeshService, RssService], }, ], exports: [CommandService], diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index 245b8d9..95e0738 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -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(); diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts index dfb41c8..673ad50 100644 --- a/src/commands/command.service.ts +++ b/src/commands/command.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { BROADCAST_ADDR } from '../mesh/codec.js'; -// type-only: the instance comes from DI (command.module.ts factory) or direct construction in tests — no runtime import cycle with src/mesh +// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss import type { MeshService } from '../mesh/mesh.service.js'; +import type { RssService } from '../rss/rss.service.js'; /** Context handed to a command handler: the trimmed text plus sender/packet id when present. */ export interface CommandContext { @@ -28,10 +29,16 @@ export class CommandService { ); // single registration point: the constructor registers every built-in command — add the next one there (or via register()) private readonly commands = new Map<string, CommandHandler>(); + // prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name + private readonly prefixes = new Map<string, CommandHandler>(); /** mesh is optional so tests can construct without a bridge; production DI always passes one. */ - constructor(private readonly mesh?: MeshService) { + constructor( + private readonly mesh?: MeshService, + rss?: RssService, + ) { this.register('ping', () => '#pong!'); + if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx)); } /** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */ @@ -39,9 +46,14 @@ export class CommandService { this.commands.set(name, fn); } + /** Register a prefix handler: owns bare `#<name>` and everything starting with `#<name> `. Exact matches win over prefixes. */ + registerPrefix(name: string, fn: CommandHandler): void { + this.prefixes.set(name, fn); + } + /** * Event-driven entry point called by MeshService for every decoded rx text. - * No-op unless on COMMAND_CHANNEL with an exact `#command` match (case-sensitive). + * No-op unless on COMMAND_CHANNEL with a case-sensitive `#command` match — exact name or registered prefix family (`#rss ...`). * Replies go through MeshService.respond(id, reply): auto-routed to the original message's * destination and channel as a threaded quote (replyId = id). Only when the trigger has no packet * id does it fall back to legacy sendText routing on COMMAND_CHANNEL. Never throws — a failed @@ -56,7 +68,17 @@ export class CommandService { if (channel !== this.commandChannel) return; const trimmed = text.trim(); if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers - const handler = this.commands.get(trimmed.slice(1)); + const name = trimmed.slice(1); + let handler = this.commands.get(name); + if (!handler) { + for (const [prefix, fn] of this.prefixes) { + // a prefix owns bare `#<prefix>` and everything starting with `#<prefix> ` — e.g. 'rss' → #rss / #rss add ... + if (name === prefix || name.startsWith(prefix + ' ')) { + handler = fn; + break; + } + } + } if (!handler) return; // exact, case-sensitive match — no partials try { const reply = await handler({ text: trimmed, from, id }); diff --git a/src/rss/rss.feed.spec.ts b/src/rss/rss.feed.spec.ts new file mode 100644 index 0000000..51acea9 --- /dev/null +++ b/src/rss/rss.feed.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { parseFeed } from './rss.feed.js'; + +const RSS2 = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0"> + <channel> + <title>Some Feed + + First headline + https://example.com/first + + + <![CDATA[Second: with CDATA]]> + ignored + + + + https://example.com/empty-title + + +`; + +const ATOM = ` + + An Atom feed + + Only entry + + +`; + +describe('parseFeed', () => { + it('parses RSS 2.0 items in document order, keeping links where present and skipping empty titles', () => { + expect(parseFeed(RSS2)).toEqual([ + { title: 'First headline', link: 'https://example.com/first' }, + { title: 'Second: with CDATA' }, + ]); + }); + + it('parses Atom entries (single entry object, link as href attribute)', () => { + expect(parseFeed(ATOM)).toEqual([ + { title: 'Only entry', link: 'https://example.com/atom' }, + ]); + }); + + it('returns [] for malformed XML', () => { + expect(parseFeed('this is not xml at all')).toEqual([]); + expect(parseFeed('broken')).toEqual([]); + }); + + it('returns [] when there are no items or none with a usable title', () => { + expect( + parseFeed('<rss version="2.0"><channel><title>x'), + ).toEqual([]); + expect( + parseFeed(''), + ).toEqual([]); + expect( + parseFeed( + ' ', + ), + ).toEqual([]); + }); +}); diff --git a/src/rss/rss.feed.ts b/src/rss/rss.feed.ts new file mode 100644 index 0000000..d1413fc --- /dev/null +++ b/src/rss/rss.feed.ts @@ -0,0 +1,60 @@ +import { XMLParser } from 'fast-xml-parser'; + +/** One feed item: title always present (non-empty), link where the feed provides one. */ +export interface FeedItem { + title: string; + link?: string; +} + +const parser = new XMLParser({ ignoreAttributes: false }); + +function asText(value: unknown): string | undefined { + if (typeof value === 'string') return value.trim() || undefined; + // element with attributes/CDATA parses to an object holding its text under #text + const t = (value as Record)?.['#text']; + if (typeof t === 'string' && t.trim()) return t.trim(); + return undefined; +} + +function asLink(value: unknown): string | undefined { + const fromText = asText(value); + if (fromText) return fromText; // RSS 2.0 url + // Atom — the URL is an attribute + const href = (value as Record)?.['@_href']; + if (typeof href === 'string' && href.trim()) return href.trim(); + return undefined; +} + +function collect(value: unknown): Record[] { + // a single item/entry parses to an object, several to an array + if (!value) return []; + const arr = Array.isArray(value) ? value : [value]; + return arr.filter( + (v): v is Record => !!v && typeof v === 'object', + ); +} + +/** + * Parse RSS 2.0 (`/<link>`) or Atom (`<entry><title>`) XML into items with + * non-empty titles, in document order (feeds are newest-first). Returns [] for malformed + * input or feeds without usable items — that empty result IS the "not a valid feed" signal. + */ +export function parseFeed(xml: string): FeedItem[] { + let doc: unknown; + try { + doc = parser.parse(xml); + } catch { + return []; // fast-xml-parser is lenient, but a hard failure means no items either + } + const root = doc as Record<string, any>; + // coalesce the RAW values first — collect() always returns an array, so `collect(a) ?? collect(b)` could never fall through to b + const rawItems = collect(root?.rss?.channel?.item ?? root?.feed?.entry); + const items: FeedItem[] = []; + for (const item of rawItems) { + const title = asText(item?.title); + if (!title) continue; // skip entries without a usable title + const link = asLink(item.link); + items.push(link ? { title, link } : { title }); + } + return items; +} diff --git a/src/rss/rss.module.ts b/src/rss/rss.module.ts new file mode 100644 index 0000000..82141f4 --- /dev/null +++ b/src/rss/rss.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { LlmModule } from '../llm/llm.module.js'; +import { LlmService } from '../llm/llm.service.js'; +import { RssService } from './rss.service.js'; + +// The rss_sources table lives in the SAME file as the mesh message history (MESH_DB_PATH) — a second +// connection is fine, SQLite serializes writes. The service initializes its own table on construction. +function openRssDb(): DatabaseSync { + const path = process.env.MESH_DB_PATH ?? './data/mesh-bot.db'; + mkdirSync(dirname(path), { recursive: true }); + return new DatabaseSync(path); +} + +@Module({ + imports: [LlmModule], // one-way — LlmService has no dependencies of its own + providers: [ + { + provide: RssService, + useFactory: (llm: LlmService) => new RssService(llm, openRssDb()), + inject: [LlmService], + }, + ], + exports: [RssService], +}) +export class RssModule {} diff --git a/src/rss/rss.service.spec.ts b/src/rss/rss.service.spec.ts new file mode 100644 index 0000000..480891b --- /dev/null +++ b/src/rss/rss.service.spec.ts @@ -0,0 +1,206 @@ +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 { RssStore } from './rss.store.js'; +import { RssService } from './rss.service.js'; + +const FEED_A = `<rss version="2.0"><channel> + <item><title>Alpha headline + Beta headline +`; +const FEED_B = ` + Gamma headline +`; + +function feedResponse(xml: string): Response { + return { ok: true, status: 200, text: async () => xml } as Response; +} + +describe('RssService.handle', () => { + let dir: string; + let dbFile: string; + let llm: { chat: ReturnType }; + let svc: RssService; + let fetchMock: ReturnType; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-svc-')); + dbFile = join(dir, 'test.db'); + llm = { chat: vi.fn() }; + svc = new RssService( + llm as unknown as LlmService, + new DatabaseSync(dbFile), + ); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + }); + + const ctx = (text: string) => ({ text }); + // second connection — the same shape production uses for a separate table in one file + const stored = () => new RssStore(new DatabaseSync(dbFile)); + + it('replies with usage on bare #rss and on unknown subcommands', async () => { + const help = await svc.handle(ctx('#rss')); + expect(help).toContain('#rss add '); + expect(help).toContain('#rss get '); + expect(await svc.handle(ctx('#rss frobnicate'))).toBe(help); + }); + + it('add: validates the feed, stores source with LLM tags, replies added (tags: ...)', async () => { + fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); + llm.chat.mockResolvedValueOnce('#AI, News'); + + const reply = await svc.handle( + ctx('#rss add https://a.example/rss my feed'), + ); + + expect(reply).toBe('added my feed (tags: ai, news)'); + expect(stored().get('my feed')).toMatchObject({ + url: 'https://a.example/rss', + tags: ['ai', 'news'], + }); + const [messages, opts] = llm.chat.mock.calls[0]; + expect(opts).toEqual({ maxTokens: 50 }); + expect(messages[0].content).toContain('Alpha headline'); + }); + + it('add: LLM failure still stores the source with empty tags and says so', async () => { + fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); + llm.chat.mockRejectedValueOnce(new Error('llm down')); + + const reply = await svc.handle( + ctx('#rss add https://a.example/rss myfeed'), + ); + + expect(reply).toBe('added myfeed (tag generation failed - no tags)'); + expect(stored().get('myfeed')?.tags).toEqual([]); + }); + + it('add: rejects invalid urls without fetching or storing', async () => { + expect(await svc.handle(ctx('#rss add not a url x'))).toBe('invalid url'); + expect(await svc.handle(ctx('#rss add ftp://a.example/rss x'))).toBe( + 'invalid url', + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stored().list()).toEqual([]); + }); + + it('add: rejects duplicate names case-insensitively without fetching', async () => { + stored().add({ name: 'MyFeed', url: 'https://old.example/rss', tags: [] }); + + const reply = await svc.handle( + ctx('#rss add https://a.example/rss myfeed'), + ); + + expect(reply).toBe('myfeed already exists - remove it first'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stored().get('myfeed')?.url).toBe('https://old.example/rss'); // untouched + }); + + it('add: reports non-2xx and unparseable feeds, stores nothing', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response); + expect(await svc.handle(ctx('#rss add https://a.example/rss x'))).toBe( + 'feed fetch failed (HTTP 404)', + ); + + fetchMock.mockResolvedValueOnce(feedResponse('this is not xml')); + expect(await svc.handle(ctx('#rss add https://b.example/rss y'))).toBe( + 'not a valid feed (no items)', + ); + expect(stored().list()).toEqual([]); + }); + + it('remove: deletes case-insensitively, reports missing names', async () => { + stored().add({ name: 'Gone', url: 'https://g.example/rss', tags: [] }); + expect(await svc.handle(ctx('#rss remove gone'))).toBe('removed gone'); + expect(stored().list()).toEqual([]); + expect(await svc.handle(ctx('#rss remove nope'))).toBe( + 'no source named nope', + ); + }); + + it('tags: no tags yet when empty, distinct sorted list otherwise', async () => { + expect(await svc.handle(ctx('#rss tags'))).toBe('no tags yet'); + stored().add({ + name: 'A', + url: 'https://a.example/rss', + tags: ['zeta', 'alpha'], + }); + stored().add({ + name: 'B', + url: 'https://b.example/rss', + tags: ['beta', 'alpha'], + }); + expect(await svc.handle(ctx('#rss tags'))).toBe('alpha, beta, zeta'); + }); + + it('sources: no sources yet when empty, names otherwise', async () => { + expect(await svc.handle(ctx('#rss sources'))).toBe('no sources yet'); + stored().add({ name: 'Bee', url: 'https://b.example/rss', tags: [] }); + stored().add({ name: 'Aye', url: 'https://a.example/rss', tags: [] }); + expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee'); + }); + + it('get : summarizes the latest titles via chat with a bounded maxTokens', async () => { + stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] }); + fetchMock.mockResolvedValueOnce(feedResponse(FEED_A)); + llm.chat.mockResolvedValueOnce('Alpha and beta happened.'); + + const reply = await svc.handle(ctx('#rss get myfeed')); + + expect(reply).toBe('Alpha and beta happened.'); + const [messages, opts] = llm.chat.mock.calls[0]; + expect(opts).toEqual({ maxTokens: 200 }); + expect(messages[0].content).toContain('Alpha headline'); + expect(messages[0].content).toContain('Beta headline'); + }); + + it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => { + stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] }); + stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] }); + stored().add({ name: 'C', url: 'https://c.example/rss', tags: ['other'] }); // not matched by #tech + fetchMock + .mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta) + .mockRejectedValueOnce(new Error('down')); // B is dead + + llm.chat.mockResolvedValueOnce('Only alpha survived.'); + const reply = await svc.handle(ctx('#rss get #tech')); + + // B's fetch rejected, yet the summary still went out from A's titles alone + expect(reply).toBe('Only alpha survived.'); + expect(llm.chat).toHaveBeenCalledTimes(1); + const content = llm.chat.mock.calls[0][0][0].content; + expect(content).toContain('Alpha headline'); + }); + + it('get #tag: merges titles from every source carrying the tag', async () => { + stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] }); + stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] }); + fetchMock + .mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta + .mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma + + llm.chat.mockResolvedValueOnce('all three.'); + await svc.handle(ctx('#rss get #tech')); + + const content = llm.chat.mock.calls[0][0][0].content; + expect(content).toContain('Alpha headline'); + expect(content).toContain('Gamma headline'); // proves B's feed was collected too + }); + + it('get: unknown name and tag get distinct error replies', async () => { + expect(await svc.handle(ctx('#rss get nope'))).toBe('no source named nope'); + stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] }); + expect(await svc.handle(ctx('#rss get #nothing'))).toBe( + 'no sources with tag nothing', + ); + }); +}); diff --git a/src/rss/rss.service.ts b/src/rss/rss.service.ts new file mode 100644 index 0000000..8ffdd3a --- /dev/null +++ b/src/rss/rss.service.ts @@ -0,0 +1,205 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DatabaseSync } from 'node:sqlite'; +// type-only both ways — no runtime import cycle between src/rss and src/commands +import type { CommandContext } from '../commands/command.service.js'; +import { LlmService } from '../llm/llm.service.js'; +import { parseFeed } from './rss.feed.js'; +import { RssStore } from './rss.store.js'; + +// ponytail: hardcoded tunables, promote to env if the owner wants them adjustable +const FETCH_TIMEOUT_MS = 15000; // feed fetches — same budget as the opencode notifier's POSTs +const MAX_TITLES = 8; // "up to 8 latest item titles" per the design +const TAG_MAX_TOKENS = 50; +const SUMMARY_MAX_TOKENS = 200; + +/** + * The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with + * LLM-generated tags and on-demand headline summaries. All replies are short strings — + * CommandService routes them through MeshService.respond() like any other command reply. + */ +@Injectable() +export class RssService { + private readonly logger = new Logger(RssService.name); + private readonly store: RssStore; + + constructor( + private readonly llm: LlmService, + db: DatabaseSync, + ) { + this.store = new RssStore(db); + } + + /** Entry point for the `#rss` prefix — always replies (errors included). */ + async handle(ctx: CommandContext): Promise { + const words = ctx.text.trim().split(/\s+/); + switch (words[1]) { + case undefined: // bare `#rss` + case 'help': + return this.help(); + case 'add': + return this.add(words.slice(2)); + case 'remove': + return this.remove(words.slice(2).join(' ')); + case 'tags': + return this.tags(); + case 'sources': + return this.sources(); + case 'get': + return this.get((words[2] ?? '').trim()); + default: + return this.help(); // unknown subcommand → help + } + } + + private help(): string { + return [ + '#rss add - add source with AI tags', + '#rss remove - delete a source', + '#rss get - summarize latest items', + '#rss tags - all distinct tags', + '#rss sources - all source names', + ].join('\n'); + } + + /** `#rss add ` — name may contain spaces (everything after the url). */ + private async add(args: string[]): Promise { + const url = args[0]; + const name = args.slice(1).join(' ').trim(); + if (!url || !name) return 'usage: #rss add '; + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return 'invalid url'; + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') + return 'invalid url'; + if (this.store.exists(name)) + return `${name} already exists - remove it first`; + + let titles: string[]; + try { + titles = await this.fetchLatestTitles(url); + } catch (err) { + const msg = (err as Error).message; + // fetchLatestTitles throws `HTTP ` on non-2xx, other errors carry their own message + return msg.startsWith('HTTP') + ? `feed fetch failed (${msg})` + : 'not a valid feed'; + } + if (!titles.length) return 'not a valid feed (no items)'; + + // AI tags — best effort: a source is still added when the LLM fails + let tags: string[] = []; + try { + const raw = await this.llm.chat( + [ + { + role: 'user', + content: `Give 2-4 short lowercase tags for these RSS feed item titles. Reply with comma-separated tags only - no # symbols, no explanation:\n${titles.join('\n')}`, + }, + ], + { maxTokens: TAG_MAX_TOKENS }, + ); + tags = sanitizeTags(raw); + } catch (err) { + this.logger.warn( + `[rss] tag generation failed for '${name}': ${(err as Error).message}`, + ); + } + + this.store.add({ name, url, tags }); + return tags.length + ? `added ${name} (tags: ${tags.join(', ')})` + : 'added ' + name + ' (tag generation failed - no tags)'; + } + + private remove(name: string): string { + if (!name) return 'usage: #rss remove '; + return this.store.remove(name) + ? `removed ${name}` + : `no source named ${name}`; + } + + private tags(): string { + const all = this.store.allTags(); + return all.length ? all.join(', ') : 'no tags yet'; + } + + private sources(): string { + const names = this.store.list().map((s) => s.name); + return names.length ? names.join(', ') : 'no sources yet'; + } + + /** `#rss get ` or `#rss get #`. */ + private async get(target: string): Promise { + if (!target) return 'usage: #rss get '; + + let urls: string[]; + if (target.startsWith('#')) { + const tag = target.slice(1).toLowerCase(); + if (!tag) return 'usage: #rss get '; + const matching = this.store.list().filter((s) => s.tags.includes(tag)); + if (!matching.length) return `no sources with tag ${tag}`; + urls = matching.map((s) => s.url); + } else { + const source = this.store.get(target); + if (!source) return `no source named ${target}`; + urls = [source.url]; + } + + // one dead feed must not sink the whole summary — collect what answers + const results = await Promise.allSettled( + urls.map((u) => this.fetchLatestTitles(u)), + ); + const titles: string[] = []; + for (const r of results) { + if (r.status === 'fulfilled') titles.push(...r.value); + else + this.logger.warn( + `[rss] feed fetch failed: ${(r.reason as Error).message}`, + ); + } + if (!titles.length) return 'no items found'; + + try { + return await this.llm.chat( + [ + { + role: 'user', + content: `Summarize these RSS headlines into a few short one-line highlights. Be concise:\n${titles.join('\n')}`, + }, + ], + { maxTokens: SUMMARY_MAX_TOKENS }, + ); + } catch (err) { + this.logger.warn(`[rss] summary failed: ${(err as Error).message}`); + return 'summary failed'; + } + } + + /** Fetch a feed and return up to MAX_TITLES latest item titles; throws on non-2xx. */ + private async fetchLatestTitles(url: string): Promise { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return parseFeed(await res.text()) + .slice(0, MAX_TITLES) + .map((i) => i.title); + } +} + +/** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */ +export function sanitizeTags(raw: string): string[] { + const seen = new Set(); + for (const part of raw.split(',')) { + const tag = part + .trim() + .toLowerCase() + .replace(/^#+/, '') + .replace(/\s+/g, '-'); + if (tag) seen.add(tag); + } + return [...seen]; +} diff --git a/src/rss/rss.store.spec.ts b/src/rss/rss.store.spec.ts new file mode 100644 index 0000000..ec27faa --- /dev/null +++ b/src/rss/rss.store.spec.ts @@ -0,0 +1,80 @@ +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 } from 'vitest'; +import { RssStore } from './rss.store.js'; + +describe('RssStore (real SQLite file)', () => { + let dir: string; + let store: RssStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-')); + const db = new DatabaseSync(join(dir, 'test.db')); + store = new RssStore(db); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('adds and lists sources with tags round-tripped through the comma string', () => { + store.add({ + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + tags: ['tech', 'news'], + }); + store.add({ name: 'LWN', url: 'https://lwn.net/headlines.rss', tags: [] }); + + expect(store.list()).toEqual([ + { + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + tags: ['tech', 'news'], + createdAt: expect.any(Number), + }, + { + name: 'LWN', + url: 'https://lwn.net/headlines.rss', + tags: [], + createdAt: expect.any(Number), + }, + ]); + }); + + it('looks up and checks existence case-insensitively', () => { + store.add({ name: 'My Feed', url: 'https://example.com/rss', tags: ['a'] }); + expect(store.get('my feed')?.url).toBe('https://example.com/rss'); + expect(store.get('MY FEED')).toBeDefined(); + expect(store.exists('mY fEeD')).toBe(true); + expect(store.exists('other')).toBe(false); + }); + + it('removes case-insensitively and reports whether a row went', () => { + store.add({ name: 'Gone', url: 'https://example.com/gone', tags: [] }); + expect(store.remove('gone')).toBe(true); + expect(store.list()).toEqual([]); + expect(store.remove('gone')).toBe(false); + }); + + it('collects all distinct tags across sources, sorted', () => { + store.add({ + name: 'A', + url: 'https://a.example/rss', + tags: ['zeta', 'alpha'], + }); + store.add({ + name: 'B', + url: 'https://b.example/rss', + tags: ['beta', 'alpha'], + }); + expect(store.allTags()).toEqual(['alpha', 'beta', 'zeta']); + }); + + it('persists across a second connection to the same file (the production shape)', () => { + store.add({ name: 'Durable', url: 'https://d.example/rss', tags: ['x'] }); + const db2 = new DatabaseSync(join(dir, 'test.db')); + expect(new RssStore(db2).get('durable')?.tags).toEqual(['x']); + }); +}); diff --git a/src/rss/rss.store.ts b/src/rss/rss.store.ts new file mode 100644 index 0000000..796de13 --- /dev/null +++ b/src/rss/rss.store.ts @@ -0,0 +1,108 @@ +import { DatabaseSync } from 'node:sqlite'; + +/** A stored RSS source; tags are a comma-separated string in the DB, split here. */ +export interface RssSource { + name: string; + url: string; + tags: string[]; + createdAt: number; +} + +/** Idempotent — safe to call on every open (the table lives in the shared MESH_DB_PATH file). */ +export function initRssTable(db: DatabaseSync): void { + db.exec(`CREATE TABLE IF NOT EXISTS rss_sources ( + name TEXT PRIMARY KEY, + url TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + created_at INTEGER + )`); +} + +/** CRUD for the rss_sources table. Names are matched case-insensitively everywhere. */ +export class RssStore { + constructor(private readonly db: DatabaseSync) { + initRssTable(db); + } + + list(): RssSource[] { + const rows = this.db + .prepare( + 'SELECT name, url, tags, created_at FROM rss_sources ORDER BY name', + ) + .all() as { + name: string; + url: string; + tags: string; + created_at: number; + }[]; + return rows.map((r) => ({ + name: r.name, + url: r.url, + tags: splitTags(r.tags), + createdAt: r.created_at, + })); + } + + /** Case-insensitive lookup by name. */ + get(name: string): RssSource | undefined { + const row = this.db + .prepare( + 'SELECT name, url, tags, created_at FROM rss_sources WHERE name = ? COLLATE NOCASE', + ) + .get(name) as + | { name: string; url: string; tags: string; created_at: number } + | undefined; + return ( + row && { + name: row.name, + url: row.url, + tags: splitTags(row.tags), + createdAt: row.created_at, + } + ); + } + + /** Case-insensitive duplicate check. */ + exists(name: string): boolean { + const row = this.db + .prepare('SELECT 1 FROM rss_sources WHERE name = ? COLLATE NOCASE') + .get(name); + return row !== undefined; + } + + add(source: { name: string; url: string; tags: string[] }): void { + this.db + .prepare( + 'INSERT INTO rss_sources (name, url, tags, created_at) VALUES (?, ?, ?, ?)', + ) + .run(source.name, source.url, joinTags(source.tags), Date.now()); + } + + /** Case-insensitive delete; true when a row was removed. */ + remove(name: string): boolean { + const result = this.db + .prepare('DELETE FROM rss_sources WHERE name = ? COLLATE NOCASE') + .run(name); + return Number(result.changes) > 0; + } + + /** All distinct tags across sources, sorted. */ + allTags(): string[] { + const set = new Set(); + for (const source of this.list()) { + for (const tag of source.tags) set.add(tag); + } + return [...set].sort(); + } +} + +export function splitTags(raw: string): string[] { + return raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); +} + +export function joinTags(tags: string[]): string { + return tags.join(','); +}