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.
109 lines
2.8 KiB
TypeScript
109 lines
2.8 KiB
TypeScript
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<string>();
|
|
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(',');
|
|
}
|