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 import type { MeshService } from '../mesh/mesh.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 to send back, or void/undefined for no reply. */ export type CommandHandler = ( ctx: CommandContext, ) => 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, ); // single registration point: the constructor registers every built-in command — add the next one there (or via register()) private readonly commands = new Map(); /** mesh is optional so tests can construct without a bridge; production DI always passes one. */ constructor(private readonly mesh?: MeshService) { this.register('ping', () => '#pong!'); } /** 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); } /** * 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). * 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 handler = this.commands.get(trimmed.slice(1)); 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 if (id !== undefined && this.mesh) { // respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote) await this.mesh.respond(id, reply); } else { // defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL await this.mesh?.sendText( from ?? BROADCAST_ADDR, reply, undefined, this.commandChannel, ); } } catch (err) { this.logger.warn( `[commands] '${trimmed}' failed: ${(err as Error).message}`, ); } } }