import { Injectable, Logger } from '@nestjs/common'; // pure formatting helper (no cycle: battery → mesh only) import { mention } from '../battery/battery.service.js'; import { BROADCAST_ADDR } from '../mesh/codec.js'; // 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 { /** the trimmed rx text, e.g. `#ping` */ text: string; /** sender node num — omitted for broadcasts/unknown senders */ from?: number; /** packet id of the triggering message */ id?: number; } /** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */ export type CommandHandler = ( ctx: CommandContext, ) => string | string[] | undefined | Promise; @Injectable() export class CommandService { private readonly logger = new Logger(CommandService.name); // read at construction, like other services' env config private readonly commandChannel = parseInt( process.env.COMMAND_CHANNEL ?? '2', 10, ); // spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config. private readonly multiSendDelayMs = parseInt( process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000', 10, ); // single registration point: the constructor registers every built-in command — add the next one there (or via register()) private readonly commands = new Map(); // prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name private readonly prefixes = new Map(); /** mesh is optional so tests can construct without a bridge; production DI always passes one. */ constructor( private readonly mesh?: MeshService, rss?: RssService, ) { this.register('ping', () => '#pong!'); this.register('loser', () => this.loserReply()); // single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line this.register('help', () => [ '#ping - alive check', '#loser - worst delivery record', '#rss help - rss subcommands', '#rss add - add feed with AI tags', '#rss remove - drop feed', '#rss get - news summary', '#rss sources / #rss tags - list feeds/tags', ].join('\n'), ); if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx)); } /** * #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide * loss stats, so the only real data is our own tx history: group by destination, count delivered vs * failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and * the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most * failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars. */ private async loserReply(): Promise { const mesh = this.mesh; if (!mesh) return 'not enough data yet - only directed sends count (broadcasts have no acks)'; const stats = new Map(); for (const m of mesh.messages()) { if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR) continue; // directed sends only if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet const s = stats.get(m.to) ?? { failed: 0, delivered: 0 }; if (m.status === 'failed') s.failed += 1; else s.delivered += 1; stats.set(m.to, s); } if (stats.size === 0) return 'not enough data yet - only directed sends count (broadcasts have no acks)'; let worst: { num: number; failed: number; total: number } | undefined; for (const [num, s] of stats) { if (s.failed === 0) continue; // zero-failure nodes can't be the loser const total = s.failed + s.delivered; // most failures first, then worse failure ratio on a tie if ( !worst || s.failed > worst.failed || (s.failed === worst.failed && s.failed / total > worst.failed / worst.total) ) { worst = { num, failed: s.failed, total }; } } if (!worst) return 'no failed sends yet - everyone delivers :)'; const names = new Map( (await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]), ); const name = names.get(worst.num); const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num); return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`; } /** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */ register(name: string, fn: CommandHandler): void { this.commands.set(name, fn); } /** Register a prefix handler: owns bare `#` and everything starting with `# `. 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 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 * command must not crash the app. */ async onRx( channel: number, text: string, from?: number, id?: number, ): Promise { if (channel !== this.commandChannel) return; const trimmed = text.trim(); if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers const name = trimmed.slice(1); let handler = this.commands.get(name); if (!handler) { for (const [prefix, fn] of this.prefixes) { // a prefix owns bare `#` and everything starting with `# ` — 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 }); if (reply === undefined || reply === null) return; // void = no reply // an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line) const lines = Array.isArray(reply) ? reply : [reply]; for (let i = 0; i < lines.length; i++) { if (i > 0 && this.multiSendDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.multiSendDelayMs), ); } const line = lines[i]; if (id !== undefined && this.mesh) { // respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote) await this.mesh.respond(id, line); } else { // defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL await this.mesh?.sendText( from ?? BROADCAST_ADDR, line, undefined, this.commandChannel, ); } } } catch (err) { this.logger.warn( `[commands] '${trimmed}' failed: ${(err as Error).message}`, ); } } }