Add channel-2 #command auto-responder (v0.18.0)
CI / build + unit (pull_request) Successful in 41s

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).
This commit is contained in:
2026-09-25 17:11:52 +00:00
parent 2b2e494023
commit 931b42bb1e
11 changed files with 322 additions and 3 deletions
+164
View File
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import {
startBridge,
textPacketFrame,
waitFor,
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import type { MeshService } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { CommandService } from './command.service.js';
function makeService(
sendText?: ReturnType<typeof vi.fn>,
): [CommandService, MeshService] {
const mesh = { sendText: sendText ?? vi.fn() } as unknown as MeshService;
return [new CommandService(mesh), mesh];
}
beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
});
describe('CommandService.onRx', () => {
it('answers #ping on the command channel with a directed DM back to the sender', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42, 7);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('broadcasts the reply when there is no sender', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping');
expect(mesh.sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'#pong!',
undefined,
2,
);
});
it('ignores the same text on other channels (ch0 and ch3)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(0, '#ping', 42);
await svc.onRx(3, '#ping', 42);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('ignores non-command text on the command channel', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, 'hello there', 42);
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('matches command names exactly and case-sensitively', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#Ping', 42);
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('trims surrounding whitespace before matching', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, ' #ping ', 42);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('honors the COMMAND_CHANNEL env override', async () => {
process.env.COMMAND_CHANNEL = '5';
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // old default no longer triggers
expect(mesh.sendText).not.toHaveBeenCalled();
await svc.onRx(5, '#ping', 42);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 5);
});
it('sends nothing when a handler returns void', async () => {
const [svc, mesh] = makeService();
svc.register('silent', () => undefined);
await svc.onRx(2, '#silent', 42);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('is extensible: a second registered command dispatches through the same path', async () => {
const [svc, mesh] = makeService();
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
await svc.onRx(2, '#status', 99, 5);
expect(mesh.sendText).toHaveBeenCalledWith(99, 'up from 99', undefined, 2);
});
it('swallows a failing handler (a bad command must not crash the app)', async () => {
const [svc, mesh] = makeService();
svc.register('boom', () => {
throw new Error('kaboom');
});
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('swallows a failing send (mesh down) without throwing', async () => {
const [svc] = makeService(
vi.fn().mockRejectedValue(new Error('no bridge')),
);
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined();
});
});
describe('MeshService wiring (event-driven trigger)', () => {
it('calls onRx for decoded rx text and answers #ping over the wire', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new RealMeshService();
try {
// wire the responder exactly like command.module.ts does in production
svc.attachCommands(new CommandService(svc));
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: '#pong!',
channel: 2,
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('does not answer #ping arriving on a non-command channel', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new RealMeshService();
try {
svc.attachCommands(new CommandService(svc));
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
expect(svc.messages()[0]).toMatchObject({
dir: 'rx',
text: '#ping',
channel: 3,
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});