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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user