Files
MeshBot/src/commands/command.service.ts
T
kamilkrzywda 931b42bb1e
CI / build + unit (pull_request) Successful in 41s
Add channel-2 #command auto-responder (v0.18.0)
Event-driven CommandService dispatches exact case-sensitive #command
matches on COMMAND_CHANNEL (default 2) through a single-registration-point
registry; first command #ping -> #pong!. Replies ride COMMAND_CHANNEL:
directed DM back to the sender when present, else broadcast. Failing
handler/send only logs a warn.

DI: CommandModule's factory injects MeshService and attaches itself via
MeshService.attachCommands() — a constructor-level cycle leaves one side
null in NestJS 12's injector (verified), so no forwardRef is used.

Tests 106 -> 121; docs synced (AGENTS.md, MESHTASTIC.md, README).
2026-09-25 17:11:52 +00:00

75 lines
2.8 KiB
TypeScript

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<string | undefined>;
@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<string, CommandHandler>();
/** 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 ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast.
* Never throws — a failed command must not crash the app.
*/
async onRx(
channel: number,
text: string,
from?: number,
id?: number,
): Promise<void> {
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
await this.mesh?.sendText(
from ?? BROADCAST_ADDR,
reply,
undefined,
this.commandChannel,
);
} catch (err) {
this.logger.warn(
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
);
}
}
}