diff --git a/AGENTS.md b/AGENTS.md index d02f66d..4ca8ec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,3 +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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae07d0a..470bb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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!`. +- Reply routing v1: directed DM back to the sender when present, else broadcast — always on `COMMAND_CHANNEL`, via the existing `MeshService.sendText(to, text, undefined, channel)` path. No threading/`replyId` in v1. A failing handler or send only logs a warn; it never crashes the app. +- Wiring: `MeshService` gains an optional third constructor param (`commands?`) plus a public `attachCommands()` — the rx handler calls `this.commands?.onRx(channel, text, from, id)` right after `notifyRx()`, fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so `CommandModule` provides `CommandService` via a factory that injects `MeshService` and attaches itself as a side effect — no `forwardRef`, no module cycle. +- Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, `COMMAND_CHANNEL` override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (`#ping` on ch2 answered with a directed `#pong!` tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances. +- Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains `COMMAND_CHANNEL`. + ## v0.17.0 — 2026-09-25 16:18 - The mesh→agent notify gate is now a comma-separated multi-channel list: `MESH_NOTIFY_CHANNEL` (default `3`) parses to channel indices, each entry trimmed, empty/invalid entries ignored (`"3,0"` → `[3, 0]`). diff --git a/MESHTASTIC.md b/MESHTASTIC.md index 2db4b86..51bd19e 100644 --- a/MESHTASTIC.md +++ b/MESHTASTIC.md @@ -22,6 +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. ## Notifications (mesh → agent) diff --git a/README.md b/README.md index d678592..ca3d247 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr - `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel) - `MESH_NOTIFY_CHANNEL` (default `3`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified) +- `COMMAND_CHANNEL` (default `2`) — channel index of the auto-responder: exact `#command` texts on that channel get an automatic reply (directed back to the sender, else broadcast); currently only `#ping` → `#pong!` - `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation) - `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000 - `PORT` (default `3000`) — HTTP listen port diff --git a/package.json b/package.json index 924ba23..4b83b83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.17.0", + "version": "0.18.0", "description": "", "author": "", "private": true, diff --git a/src/app.module.ts b/src/app.module.ts index 73e7afc..177fa27 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { BatteryAlertModule } from './battery/battery.module.js'; +import { CommandModule } from './commands/command.module.js'; import { MeshModule } from './mesh/mesh.module.js'; import { WeatherModule } from './weather/weather.module.js'; @Module({ - imports: [MeshModule, WeatherModule, BatteryAlertModule], + imports: [MeshModule, WeatherModule, BatteryAlertModule, CommandModule], }) export class AppModule {} diff --git a/src/commands/command.module.ts b/src/commands/command.module.ts new file mode 100644 index 0000000..3bf538b --- /dev/null +++ b/src/commands/command.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MeshModule } from '../mesh/mesh.module.js'; +import { MeshService } from '../mesh/mesh.service.js'; +import { CommandService } from './command.service.js'; + +@Module({ + imports: [MeshModule], // one-way only — the reverse edge is wired in the factory below, not via DI + providers: [ + { + provide: CommandService, + useFactory: (mesh: MeshService) => { + const commands = new CommandService(mesh); + // break the cycle: MeshService can't inject us (its own factory would deadlock on this edge), so attach ourselves once we exist — mesh is guaranteed created here because it's our injected dependency + mesh.attachCommands(commands); + return commands; + }, + inject: [MeshService], + }, + ], + exports: [CommandService], +}) +export class CommandModule {} diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts new file mode 100644 index 0000000..8d0599f --- /dev/null +++ b/src/commands/command.service.spec.ts @@ -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, +): [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(); + } + }); +}); diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts new file mode 100644 index 0000000..a49bf5f --- /dev/null +++ b/src/commands/command.service.ts @@ -0,0 +1,74 @@ +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; + +@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(); + + /** 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 ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast. + * Never throws — a failed command must not crash the app. + */ + async onRx( + channel: number, + text: string, + from?: number, + id?: number, + ): Promise { + 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 + 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/commands/di.spec.ts b/src/commands/di.spec.ts new file mode 100644 index 0000000..84739db --- /dev/null +++ b/src/commands/di.spec.ts @@ -0,0 +1,31 @@ +import { Test } from '@nestjs/testing'; +import { describe, expect, it } from 'vitest'; +import { MeshService } from '../mesh/mesh.service.js'; +import { CommandModule } from './command.module.js'; +import { CommandService } from './command.service.js'; + +describe('DI wiring (module graph)', () => { + // compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened. + it('resolves CommandService with MeshService provided by MeshModule', async () => { + process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data + const moduleRef = await Test.createTestingModule({ + imports: [CommandModule], + }).compile(); + expect(moduleRef.get(CommandService)).toBeInstanceOf(CommandService); + expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService); + }); + + // the MeshService <-> CommandService cycle must resolve to live references on both sides, not undefined + it('wires both sides of the circular dependency with real instances', async () => { + process.env.MESH_DB_PATH = ':memory:'; + const moduleRef = await Test.createTestingModule({ + imports: [CommandModule], + }).compile(); + const mesh = moduleRef.get(MeshService); + const commands = moduleRef.get(CommandService); + expect((commands as unknown as { mesh?: MeshService }).mesh).toBe(mesh); + expect((mesh as unknown as { commands?: CommandService }).commands).toBe( + commands, + ); + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 5b674b6..2b76794 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -11,6 +11,8 @@ import * as net from 'node:net'; // type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests) import type { DatabaseSync } from 'node:sqlite'; import type * as Proto from '@meshtastic/protobufs'; +// type-only: the instance arrives via attachCommands() in production (command.module.ts) or the constructor in tests — no runtime import cycle with src/commands +import type { CommandService } from '../commands/command.service.js'; import { OpencodeService } from '../opencode/opencode.service.js'; import { BROADCAST_ADDR, @@ -211,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { private db: DatabaseSync | null = null; /** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */ private readonly opencode: OpencodeService | null; + /** Event-driven `#command` auto-responder (COMMAND_CHANNEL); null until wired — via the constructor (tests) or attachCommands() (DI). */ + private commands: CommandService | null = null; private pollTimer: NodeJS.Timeout | null = null; - constructor(db?: DatabaseSync, opencode?: OpencodeService) { + constructor( + db?: DatabaseSync, + opencode?: OpencodeService, + commands?: CommandService, + ) { this.db = db ?? null; this.opencode = opencode ?? null; + this.commands = commands ?? null; + } + + /** Wire the `#command` auto-responder after construction — used by command.module.ts to break the DI cycle (a constructor-level MeshService <-> CommandService cycle leaves one side null in NestJS's injector). */ + attachCommands(commands: CommandService): void { + this.commands = commands; } private socket: net.Socket | null = null; @@ -736,6 +750,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { at: Date.now(), }); this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to); + // event-driven #command auto-responder (COMMAND_CHANNEL) — fire-and-forget like the notifier + void this.commands?.onRx(pkt.channel ?? 0, text, pkt.from, pkt.id); } else if (decoded && decoded.portnum === PORT_POSITION_APP) { // live position update: the Position is serialized in Data.payload let pos: Proto.Mesh.Position | undefined;