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,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 });
|
||||
|
||||
Reference in New Issue
Block a user