Add #rss command family with LLM tags and summaries (v0.20.0)
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:
2026-09-25 18:44:06 +00:00
parent 023cd3eef3
commit eda1cbd125
15 changed files with 993 additions and 14 deletions
+205
View File
@@ -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<string> {
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 <url> <name> - add source with AI tags',
'#rss remove <name> - delete a source',
'#rss get <name|#tag> - summarize latest items',
'#rss tags - all distinct tags',
'#rss sources - all source names',
].join('\n');
}
/** `#rss add <url> <name>` — name may contain spaces (everything after the url). */
private async add(args: string[]): Promise<string> {
const url = args[0];
const name = args.slice(1).join(' ').trim();
if (!url || !name) return 'usage: #rss add <url> <name>';
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 <status>` 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 <name>';
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 <name>` or `#rss get #<tag>`. */
private async get(target: string): Promise<string> {
if (!target) return 'usage: #rss get <name|#tag>';
let urls: string[];
if (target.startsWith('#')) {
const tag = target.slice(1).toLowerCase();
if (!tag) return 'usage: #rss get <name|#tag>';
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<string[]> {
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<string>();
for (const part of raw.split(',')) {
const tag = part
.trim()
.toLowerCase()
.replace(/^#+/, '')
.replace(/\s+/g, '-');
if (tag) seen.add(tag);
}
return [...seen];
}