83 lines
3.3 KiB
TypeScript
83 lines
3.3 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 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<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
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|