From 9d4c9594af48e0d839dc2c654e83e360eb38487e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 17:30:28 +0000 Subject: [PATCH] =?UTF-8?q?Route=20command=20replies=20through=20MeshServi?= =?UTF-8?q?ce.respond()=20=E2=80=94=20same=20channel,=20threaded=20quote?= =?UTF-8?q?=20(v0.18.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ MESHTASTIC.md | 2 +- package.json | 2 +- src/commands/command.service.spec.ts | 108 ++++++++++++++++++++++++--- src/commands/command.service.ts | 24 ++++-- src/mesh/fake-bridge.ts | 19 ++++- src/mesh/mesh.service.spec.ts | 16 +--- 8 files changed, 142 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ca8ec3..4dba470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,4 +27,4 @@ - MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages. - Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`. -- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies ride `COMMAND_CHANNEL`: directed DM back to the sender when present, else broadcast. A failing handler/send only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. +- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. diff --git a/CHANGELOG.md b/CHANGELOG.md index 470bb28..f29e354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.18.1 — 2026-09-25 17:30 + +- Command replies now ride `MeshService.respond(id, reply)` instead of direct `sendText` routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the **original message's channel** with `replyId = id`, so the recipient app renders it as a threaded quote of the triggering packet. `COMMAND_CHANNEL` is now only the trigger/listen gate — the reply channel comes from the record. +- Defensive fallback: when the trigger has no packet id (never happens in production — `MeshService` always passes `pkt.id`), the legacy sendText routing on `COMMAND_CHANNEL` (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes. +- Tests: 121 → 124 specs — unit cases now mock `mesh.respond` (id present → `respond(id, reply)` with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: `#ping` broadcast on ch2 → `#pong!` broadcast on ch2 with `replyId` = ping packet id, and `#ping` DM → `#pong!` DM to the sender on ch2 with `replyId` set. All gate tests (case/whitespace/channel/env override) kept. +- The wire-decode test helper `outboundPackets()` moved from `mesh.service.spec.ts` into `fake-bridge.ts` so both specs share it. +- Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (`COMMAND_CHANNEL` still exists, now meaning trigger channel only). + ## v0.18.0 — 2026-09-25 17:08 - New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`. diff --git a/MESHTASTIC.md b/MESHTASTIC.md index 51bd19e..eeae1e6 100644 --- a/MESHTASTIC.md +++ b/MESHTASTIC.md @@ -22,7 +22,7 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea - All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel. - Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted. -- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) get an automatic reply on that channel — directed back to the sender when present, else broadcast. Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything. +- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) on that channel get an automatic reply routed through `MeshService.respond()` — DM back to the original sender, broadcast when the original was a broadcast — riding the original message's channel as a threaded quote (`replyId` = the trigger packet id). Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything. ## Notifications (mesh → agent) diff --git a/package.json b/package.json index 4b83b83..40999be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.18.0", + "version": "0.18.1", "description": "", "author": "", "private": true, diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index 8d0599f..245b8d9 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -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, + respond?: ReturnType, ): [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(); diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts index a49bf5f..dfb41c8 100644 --- a/src/commands/command.service.ts +++ b/src/commands/command.service.ts @@ -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}`, diff --git a/src/mesh/fake-bridge.ts b/src/mesh/fake-bridge.ts index 39c3c3d..87bd96d 100644 --- a/src/mesh/fake-bridge.ts +++ b/src/mesh/fake-bridge.ts @@ -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 { diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index e75cf65..7e78fdb 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -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(); -- 2.54.0