From 4ae932befe320ff2a96680523a96d28fa930b3ec Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 16:18:07 +0000 Subject: [PATCH] mesh: MESH_NOTIFY_CHANNEL as comma-separated list + ch0 DM-only notify gate; v0.17.0 --- AGENTS.md | 2 +- CHANGELOG.md | 8 +++ MESHTASTIC.md | 3 +- README.md | 6 +- package.json | 2 +- src/mesh/mesh.controller.ts | 2 +- src/mesh/mesh.service.spec.ts | 106 ++++++++++++++++++++++++++++++++++ src/mesh/mesh.service.ts | 33 ++++++++--- 8 files changed, 147 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b3af97..d02f66d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `POST /api/respond` (body `{ "messageId": , "content": "..." }`, returns `{ id }`; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries `replyId = messageId` so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from` and `to`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, 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, nothing goes to the bridge or air; 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, notifier wired; id is the packet id for real rx or the generated record id for injected, `from` omitted when there is no sender; in-memory only, reset on restart), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `POST /api/respond` (body `{ "messageId": , "content": "..." }`, returns `{ id }`; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries `replyId = messageId` so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from` and `to`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, 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, nothing goes to the bridge or air; 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 in the MESH_NOTIFY_CHANNEL list (ch0: directed DMs only), not a self-echo, notifier wired; id is the packet id for real rx or the generated record id for injected, `from` omitted when there is no sender; in-memory only, reset on restart), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list (default `3`; entries trimmed, empty/invalid ones ignored), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. Channel 0 is the shared default channel where other users talk: only directed DMs (both sender and recipient present) notify there, ch0 broadcasts are ignored. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when `0` is in the list — injections ride ch0 by design and are exempt from the ch0 DM-only rule. Messages from our own node are never notified (self-echo filter). 3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; errors surface as tool errors with the same messages), `respond(messageId, content)` (reply to an earlier message by packet id from `get_messages`; routing is automatic — rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with replyId = messageId so the recipient app renders it as a threaded quote; invalid args or an unknown id are tool errors), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else (`respond` rides the record's channel). Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c876d..ae07d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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 - 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`. diff --git a/MESHTASTIC.md b/MESHTASTIC.md index f376496..2db4b86 100644 --- a/MESHTASTIC.md +++ b/MESHTASTIC.md @@ -25,4 +25,5 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea ## 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. diff --git a/README.md b/README.md index 51005c3..d678592 100644 --- a/README.md +++ b/README.md @@ -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: {
: ...}, moduleConfig: {: ...} }`. 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 - `POST /api/messages` — body `{ "text": "...", "from"?: }`, 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/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 @@ -105,7 +105,7 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope ## 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 )[ from node ]: "" — 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 )[ from node ]: "" — 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: @@ -120,7 +120,7 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr - `MESH_PORT` (default `4403`) — bridge port - `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`) — 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) - `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 065deb7..924ba23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.16.0", + "version": "0.17.0", "description": "", "author": "", "private": true, diff --git a/src/mesh/mesh.controller.ts b/src/mesh/mesh.controller.ts index 30ab09b..efc0194 100644 --- a/src/mesh/mesh.controller.ts +++ b/src/mesh/mesh.controller.ts @@ -245,7 +245,7 @@ export class MeshController { @ApiOperation({ summary: 'Forwarded message history (last 100)', 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({ status: 200, diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index e0467d3..e75cf65 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -1046,6 +1046,112 @@ describe('MeshService opencode notifier', () => { 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', () => { diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index cf3e0be..5b674b6 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -161,6 +161,15 @@ function jsonSafe(value: T): T { 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 const REDACTED = 'REDACTED'; /** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */ @@ -190,11 +199,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { process.env.MESH_SEND_CHANNEL ?? '3', 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 - private readonly notifyChannel = parseInt( + private readonly notifyChannels = parseNotifyChannels( process.env.MESH_NOTIFY_CHANNEL ?? '3', - 10, ); // how often the poller re-upserts the history buffer into SQLite (ms) private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10); @@ -506,19 +514,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { at: Date.now(), }; this.pushHistory(rec); - // injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match - this.notifyRx(0, text, from, rec.id); + // 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, undefined, true); 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( channel: number, text: string, from?: number, id?: number, + to?: number, + injected = false, ): 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 if (from !== undefined && from === this.myNodeNum) { this.logger.debug(`notify skipped: rx from our own node ${from}`); @@ -718,7 +735,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { channel: pkt.channel ?? 0, 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); } else if (decoded && decoded.portnum === PORT_POSITION_APP) { // live position update: the Position is serialized in Data.payload let pos: Proto.Mesh.Position | undefined; -- 2.54.0