181 lines
7.9 KiB
TypeScript
181 lines
7.9 KiB
TypeScript
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<string | 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,
|
|
);
|
|
// 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<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,
|
|
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 <url> <name> - add feed with AI tags',
|
|
'#rss remove <name> - drop feed',
|
|
'#rss get <name|#tag> - 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<string> {
|
|
const mesh = this.mesh;
|
|
if (!mesh)
|
|
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
|
|
const stats = new Map<number, { failed: number; delivered: number }>();
|
|
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 `#<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 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<void> {
|
|
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 `#<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 });
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|