Compare commits

..
4 Commits
Author SHA1 Message Date
kamilkrzywda d375f2a943 Merge pull request 'v0.18.0: channel-2 auto-responder with extensible #command boilerplate' (#38) from feat/channel2-autoresponder into main
CI / build + unit (push) Successful in 1m16s
2026-09-25 19:14:15 +02:00
kamilkrzywda 931b42bb1e 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).
2026-09-25 17:11:52 +00:00
kamilkrzywda 2b2e494023 Merge pull request 'Multi-channel notify gate + ch0 DM-only filtering (v0.17.0)' (#37) from feat/multi-channel-notify into main
CI / build + unit (push) Successful in 40s
2026-09-25 18:21:16 +02:00
kamilkrzywda 4ae932befe mesh: MESH_NOTIFY_CHANNEL as comma-separated list + ch0 DM-only notify gate; v0.17.0
CI / build + unit (pull_request) Successful in 1m8s
2026-09-25 16:18:07 +00:00
13 changed files with 468 additions and 17 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -1,5 +1,21 @@
# Changelog # 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]`).
- ch0 DM-only filtering: ch0 is the shared default channel where other users talk — only directed messages (both sender and recipient present) notify there; ch0 broadcasts and undirected traffic are ignored. Synthetic injections (`POST /api/messages`, `injectMessage`) ride ch0 by design and stay exempt from the rule, so they still notify when 0 is in the list.
- `notifyRx()` gains optional params: `to?` (the packet's destination, now passed through from the rx handler) and an `injected` flag; the field is renamed `notifyChannel` → `notifyChannels`. Self-echo filter unchanged.
- Tests: 102 → 106 specs — multi-list match/non-match, ch0 broadcast ignored even with 0 in the list, ch0 directed DM notified, ch0 injection notified via a multi-list, invalid env entries ignored.
- Docs: AGENTS.md + MESHTASTIC.md "Notifications" describe the list semantics and the ch0 DM-only rule; README env bullet/forwarded/notifier lines and the controller's `GET /api/forwarded` description kept in sync. Local `.env` (gitignored) now `MESH_NOTIFY_CHANNEL=3,0`.
## v0.16.0 — 2026-09-25 15:49 ## v0.16.0 — 2026-09-25 15:49
- New `GET /api/forwarded`: the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]` — the id is the packet id (real rx) or generated record id (injected), `from` omitted when there is no sender. Backed by an in-memory ring buffer in `MeshService`, recorded in `notifyRx()` at hand-off: after the channel gate (`MESH_NOTIFY_CHANNEL`) and self-echo filter pass, right before the `OpencodeService.notify()` call; an unwired notifier records nothing (a notifier disabled internally still counts — MeshService cannot see its enabled flag). In-memory only, reset on restart. Purpose: a replying agent gets exactly the notified subset with packet ids instead of scanning all of `/api/messages`. - New `GET /api/forwarded`: the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]` — the id is the packet id (real rx) or generated record id (injected), `from` omitted when there is no sender. Backed by an in-memory ring buffer in `MeshService`, recorded in `notifyRx()` at hand-off: after the channel gate (`MESH_NOTIFY_CHANNEL`) and self-echo filter pass, right before the `OpencodeService.notify()` call; an unwired notifier records nothing (a notifier disabled internally still counts — MeshService cannot see its enabled flag). In-memory only, reset on restart. Purpose: a replying agent gets exactly the notified subset with packet ids instead of scanning all of `/api/messages`.
+3 -1
View File
@@ -22,7 +22,9 @@ 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. - 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. - 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) ## Notifications (mesh → agent)
- RX text on the notify channel (`MESH_NOTIFY_CHANNEL`, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2). - RX text on a channel in the notify list (`MESH_NOTIFY_CHANNEL`, comma-separated indices, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
- Channel 0 is the shared default channel where other users talk: it carries real DMs when both sender and recipient are present, and the bot listens there too — but only directed messages trigger notifications; ch0 broadcasts are ignored. Synthetic injected messages (channel 0) are exempt from this rule.
+4 -3
View File
@@ -32,7 +32,7 @@ All API endpoints live under the `/api` prefix:
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect - `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from` and `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows - `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from` and `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message - `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
- `GET /api/forwarded` — the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel matched `MESH_NOTIFY_CHANNEL`, not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart - `GET /api/forwarded` — the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel in the `MESH_NOTIFY_CHANNEL` list (ch0: directed DMs only), not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect - `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo - `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches - `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
@@ -105,7 +105,7 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
## Opencode notifier ## Opencode notifier
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list, it pushes it to the mesh-bot folder's live opencode session (Basic auth). On ch0 only directed DMs are pushed — broadcasts there are ignored. The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo: Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
@@ -120,7 +120,8 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
- `MESH_PORT` (default `4403`) — bridge port - `MESH_PORT` (default `4403`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `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_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index whose rx texts trigger the opencode notifier (see "Opencode notifier"; synthetic injected messages, channel 0, trigger it too when set to match; messages from our own node are never notified) - `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_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 - `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 - `PORT` (default `3000`) — HTTP listen port
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.16.0", "version": "0.18.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BatteryAlertModule } from './battery/battery.module.js'; import { BatteryAlertModule } from './battery/battery.module.js';
import { CommandModule } from './commands/command.module.js';
import { MeshModule } from './mesh/mesh.module.js'; import { MeshModule } from './mesh/mesh.module.js';
import { WeatherModule } from './weather/weather.module.js'; import { WeatherModule } from './weather/weather.module.js';
@Module({ @Module({
imports: [MeshModule, WeatherModule, BatteryAlertModule], imports: [MeshModule, WeatherModule, BatteryAlertModule, CommandModule],
}) })
export class AppModule {} export class AppModule {}
+22
View File
@@ -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 {}
+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();
}
});
});
+74
View File
@@ -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<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,
);
// 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>();
/** 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<void> {
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}`,
);
}
}
}
+31
View File
@@ -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,
);
});
});
+1 -1
View File
@@ -245,7 +245,7 @@ export class MeshController {
@ApiOperation({ @ApiOperation({
summary: 'Forwarded message history (last 100)', summary: 'Forwarded message history (last 100)',
description: description:
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel matched MESH_NOTIFY_CHANNEL, not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.', 'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
+106
View File
@@ -1046,6 +1046,112 @@ describe('MeshService opencode notifier', () => {
bridge.close(); bridge.close();
} }
}); });
it('notifies for rx texts on any channel in a comma-separated MESH_NOTIFY_CHANNEL list, and not for others', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = '2,5';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// channel 5 is in the list — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'on-list', 5));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'on-list', KAMIL_NUM);
// channel 3 is not in the list — captured in history but no notification
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'off-list', 3));
await waitFor(() => svc.messages().length === 2);
expect(notifyMock).toHaveBeenCalledTimes(1);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('ignores ch0 broadcasts even when 0 is in the list, but notifies for directed DMs', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = '3,0';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// ch0 broadcast: shared default channel noise — no notification
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 0),
);
await waitFor(() => svc.messages().length === 1);
expect(notifyMock).not.toHaveBeenCalled();
// ch0 directed DM: both sender and recipient present — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'dm', 0));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'dm', KAMIL_NUM);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('notifies for a ch0 injection when 0 is in the list (multi-list)', () => {
process.env.MESH_NOTIFY_CHANNEL = '3,0';
const svc = makeSvc();
try {
const r = svc.injectMessage('hi', 42);
// injected records are exempt from the ch0 DM-only rule
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('ignores empty and invalid entries in MESH_NOTIFY_CHANNEL', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_CHANNEL = 'abc, ,5,-2,x7';
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// only the valid entry (5) survives parsing — notified
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 5));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM);
// channel 7 only appears inside the invalid entry "x7" — must not match
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'nope', 7));
await waitFor(() => svc.messages().length === 2);
expect(notifyMock).toHaveBeenCalledTimes(1);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
}); });
describe('MeshService forwarded ring', () => { describe('MeshService forwarded ring', () => {
+42 -9
View File
@@ -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) // 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 { DatabaseSync } from 'node:sqlite';
import type * as Proto from '@meshtastic/protobufs'; 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 { OpencodeService } from '../opencode/opencode.service.js';
import { import {
BROADCAST_ADDR, BROADCAST_ADDR,
@@ -161,6 +163,15 @@ function jsonSafe<T>(value: T): T {
return value; // primitives + undefined pass through return value; // primitives + undefined pass through
} }
/** MESH_NOTIFY_CHANNEL is a comma-separated list of channel indices; empty/invalid entries are ignored. */
function parseNotifyChannels(raw: string): number[] {
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => /^\d+$/.test(s))
.map((s) => parseInt(s, 10));
}
// key material must never leave the process — replaced with a marker so callers can see the field exists // key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED'; const REDACTED = 'REDACTED';
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */ /** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
@@ -190,11 +201,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
process.env.MESH_SEND_CHANNEL ?? '3', process.env.MESH_SEND_CHANNEL ?? '3',
10, 10,
); );
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on the notify channel; // outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on one of the notify channels;
// disabled state (missing credentials) lives in OpencodeService itself // disabled state (missing credentials) lives in OpencodeService itself
private readonly notifyChannel = parseInt( private readonly notifyChannels = parseNotifyChannels(
process.env.MESH_NOTIFY_CHANNEL ?? '3', process.env.MESH_NOTIFY_CHANNEL ?? '3',
10,
); );
// how often the poller re-upserts the history buffer into SQLite (ms) // how often the poller re-upserts the history buffer into SQLite (ms)
private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10); private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10);
@@ -203,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
private db: DatabaseSync | null = null; private db: DatabaseSync | null = null;
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */ /** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
private readonly opencode: OpencodeService | null; 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; private pollTimer: NodeJS.Timeout | null = null;
constructor(db?: DatabaseSync, opencode?: OpencodeService) { constructor(
db?: DatabaseSync,
opencode?: OpencodeService,
commands?: CommandService,
) {
this.db = db ?? null; this.db = db ?? null;
this.opencode = opencode ?? 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; private socket: net.Socket | null = null;
@@ -506,19 +528,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
at: Date.now(), at: Date.now(),
}; };
this.pushHistory(rec); this.pushHistory(rec);
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match // injected records ride channel 0 — the notifier fires only when 0 is in MESH_NOTIFY_CHANNEL (injections are exempt from the ch0 DM-only rule)
this.notifyRx(0, text, from, rec.id); this.notifyRx(0, text, from, rec.id, undefined, true);
return { id: rec.id }; return { id: rec.id };
} }
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless channels match and a notifier is wired). Both call sites pass the packet id. */ /** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless the channel is in the notify list and a notifier is wired). Both call sites pass the packet id; `injected` marks synthetic records that ride ch0 by design. */
private notifyRx( private notifyRx(
channel: number, channel: number,
text: string, text: string,
from?: number, from?: number,
id?: number, id?: number,
to?: number,
injected = false,
): void { ): void {
if (!this.opencode || channel !== this.notifyChannel) return; if (!this.opencode || !this.notifyChannels.includes(channel)) return;
// ch0 is the shared default channel where other users talk — only directed DMs (a real destination) are notified there; broadcasts and undirected traffic are noise. Injected records ride ch0 by design, so they're exempt.
if (
channel === 0 &&
!injected &&
(to === undefined || to === BROADCAST_ADDR)
)
return;
// self-echo filter: our own node must never trigger a notification of itself // self-echo filter: our own node must never trigger a notification of itself
if (from !== undefined && from === this.myNodeNum) { if (from !== undefined && from === this.myNodeNum) {
this.logger.debug(`notify skipped: rx from our own node ${from}`); this.logger.debug(`notify skipped: rx from our own node ${from}`);
@@ -718,7 +749,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
channel: pkt.channel ?? 0, channel: pkt.channel ?? 0,
at: Date.now(), at: Date.now(),
}); });
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id); 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) { } else if (decoded && decoded.portnum === PORT_POSITION_APP) {
// live position update: the Position is serialized in Data.payload // live position update: the Position is serialized in Data.payload
let pos: Proto.Mesh.Position | undefined; let pos: Proto.Mesh.Position | undefined;