Route command replies through MeshService.respond() — same channel, threaded quote (v0.18.1)
CI / build + unit (pull_request) Successful in 42s

This commit is contained in:
2026-09-25 17:30:28 +00:00
parent d375f2a943
commit 9d4c9594af
8 changed files with 142 additions and 39 deletions
+97 -11
View File
@@ -4,6 +4,7 @@ import {
startBridge,
textPacketFrame,
waitFor,
outboundPackets,
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import type { MeshService } from '../mesh/mesh.service.js';
@@ -12,8 +13,12 @@ import { CommandService } from './command.service.js';
function makeService(
sendText?: ReturnType<typeof vi.fn>,
respond?: ReturnType<typeof vi.fn>,
): [CommandService, MeshService] {
const mesh = { sendText: sendText ?? vi.fn() } as unknown as MeshService;
const mesh = {
sendText: sendText ?? vi.fn(),
respond: respond ?? vi.fn().mockResolvedValue({ id: 1 }),
} as unknown as MeshService;
return [new CommandService(mesh), mesh];
}
@@ -22,15 +27,24 @@ beforeEach(() => {
});
describe('CommandService.onRx', () => {
it('answers #ping on the command channel with a directed DM back to the sender', async () => {
it('replies #ping via respond() — threaded quote of the trigger packet, no direct sendText', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(7, '#pong!');
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('broadcasts the reply when there is no sender', async () => {
it('falls back to a broadcast sendText on COMMAND_CHANNEL when the trigger has no packet id (no sender)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping');
await svc.onRx(2, '#ping'); // no from, no id — defensive path
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'#pong!',
@@ -43,6 +57,7 @@ describe('CommandService.onRx', () => {
const [svc, mesh] = makeService();
await svc.onRx(0, '#ping', 42);
await svc.onRx(3, '#ping', 42);
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
@@ -50,6 +65,7 @@ describe('CommandService.onRx', () => {
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.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
@@ -57,28 +73,31 @@ describe('CommandService.onRx', () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#Ping', 42);
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
expect(mesh.respond).not.toHaveBeenCalled();
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);
await svc.onRx(2, ' #ping ', 42, 9);
expect(mesh.respond).toHaveBeenCalledWith(9, '#pong!');
});
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.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
await svc.onRx(5, '#ping', 42);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 5);
await svc.onRx(5, '#ping', 42, 8);
expect(mesh.respond).toHaveBeenCalledWith(8, '#pong!');
});
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.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
@@ -86,7 +105,7 @@ describe('CommandService.onRx', () => {
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);
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
});
it('swallows a failing handler (a bad command must not crash the app)', async () => {
@@ -95,19 +114,28 @@ describe('CommandService.onRx', () => {
throw new Error('kaboom');
});
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('swallows a failing respond (unknown id / bridge down) without throwing', async () => {
const [svc] = makeService(
undefined,
vi.fn().mockRejectedValue(new Error('no message with id 7')),
);
await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined();
});
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();
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path
});
});
describe('MeshService wiring (event-driven trigger)', () => {
it('calls onRx for decoded rx text and answers #ping over the wire', async () => {
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
@@ -120,6 +148,10 @@ describe('MeshService wiring (event-driven trigger)', () => {
() => bridge.sockets.length === 1 && svc.status().connected,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
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])
@@ -130,6 +162,60 @@ describe('MeshService wiring (event-driven trigger)', () => {
text: '#pong!',
channel: 2,
});
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
await waitFor(() => outboundPackets(out).length >= 1);
const pkt = outboundPackets(out)[0]!;
expect(pkt.to).toBe(KAMIL_NUM);
expect(pkt.channel).toBe(2);
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(data?.replyId).toBe(1);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', 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,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
// rx broadcast from KAMIL on channel 2 (packet id 1)
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
);
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: BROADCAST_ADDR, // broadcast original → broadcast reply
text: '#pong!',
channel: 2,
});
await waitFor(() => outboundPackets(out).length >= 1);
const pkt = outboundPackets(out)[0]!;
expect(pkt.to).toBe(BROADCAST_ADDR);
expect(pkt.channel).toBe(2);
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
} finally {
svc.onModuleDestroy();
bridge.close();
+16 -8
View File
@@ -42,8 +42,10 @@ export class CommandService {
/**
* 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.
* 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,
@@ -59,12 +61,18 @@ export class CommandService {
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,
);
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}`,
+17 -2
View File
@@ -1,8 +1,8 @@
import { create, toBinary } from '@bufbuild/protobuf';
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { frame } from './codec.js';
import { extractFrames, frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
@@ -176,6 +176,21 @@ export function textPacketFrame(
);
}
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
+1 -15
View File
@@ -11,6 +11,7 @@ import {
metadataFrame,
moduleConfigSectionFrame,
nodeInfoFrame,
outboundPackets,
startBridge,
telemetryPacketFrame,
textPacketFrame,
@@ -667,21 +668,6 @@ describe('MeshService message history', () => {
});
});
// outbound text packets written to the bridge, in order (want_config frames skipped)
function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
describe('MeshService respond', () => {
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
const bridge = await startBridge();