Compare commits

...
21 Commits
Author SHA1 Message Date
kamilkrzywda ff57f3eae1 Merge pull request 'v0.15.0+v0.16.0: /api/respond reply endpoint + message-id in notifier wrapper, GET /api/forwarded ring' (#36) from feat/respond-endpoint into main
CI / build + unit (push) Successful in 1m17s
2026-09-25 17:53:55 +02:00
kamilkrzywda b7b2044d84 mesh: GET /api/forwarded — last 100 messages forwarded to opencode; v0.16.0 docs
CI / build + unit (pull_request) Successful in 41s
2026-09-25 15:51:43 +00:00
kamilkrzywda e62f831ee5 mesh: in-memory ring of the last 100 messages forwarded to opencode 2026-09-25 15:48:09 +00:00
kamilkrzywda bd18bdfd8a docs: v0.15.0 changelog, README/AGENTS respond + notifier wrapper updates, version bump 2026-09-25 15:39:52 +00:00
kamilkrzywda c33f4613b5 mesh: POST /api/respond + MCP respond tool — reply to a message by packet id 2026-09-25 15:32:34 +00:00
kamilkrzywda 88b617f8b7 opencode: notifier wrapper gains the packet id (id <n>) 2026-09-25 15:06:31 +00:00
kamilkrzywda 34e7bfe0bb mesh: persist destination (to) on captured rx history records 2026-09-25 15:02:21 +00:00
kamilkrzywda b677c05681 Merge pull request 'v0.14.0 — in-process opencode notifier replaces the forwarder service' (#35) from feat/opencode-notifier into main
CI / build + unit (push) Successful in 42s
2026-09-25 16:17:39 +02:00
kamilkrzywda 83e47c9609 docs+changelog: v0.14.0 — in-process opencode notifier replaces the forwarder
CI / build + unit (pull_request) Successful in 1m0s
2026-09-25 14:14:37 +00:00
kamilkrzywda 5eb1a79e70 mesh: replace notify webhook with in-process opencode notifier; drop forwarder service 2026-09-25 14:14:37 +00:00
kamilkrzywda 7d7c569d08 opencode: add in-process OpencodeService notifier (port of ops/forwarder) 2026-09-25 14:06:25 +00:00
kamilkrzywda c0c069ef08 Merge pull request 'Battery alerts → testowy channel (ch2) — v0.13.1' (#34) from fix/battery-alert-channel into main
CI / build + unit (push) Successful in 39s
2026-09-25 15:04:11 +02:00
kamilkrzywda 1b712f7eee battery: send alerts on channel "testowy" (index 2), not the pinned send channel
CI / build + unit (pull_request) Successful in 1m0s
Owner request. BatteryAlertService reads new env BATTERY_ALERT_CHANNEL
(default 2) at construction and passes it through MeshService.sendText,
which gains an optional trailing channel argument defaulting to the
pinned MESH_SEND_CHANNEL — every other sender is untouched. Threshold,
cooldown, merged one-message-per-cycle format and @mentions unchanged.
2026-09-25 13:02:56 +00:00
kamilkrzywda 6f6b704dcf Merge pull request 'v0.13.0 — battery-low alert module (merged broadcasts, @mentions, cooldown)' (#33) from feat/battery-alerts into main
CI / build + unit (push) Successful in 43s
2026-09-25 14:53:01 +02:00
kamilkrzywda fe8d2dbe0b battery: merge multiple low-battery nodes into one broadcast per cycle
CI / build + unit (pull_request) Successful in 1m0s
Owner follow-up: 'if multiple ppl shows at once — merge messages to one'.
check() now collects every qualifying node (at/below threshold and not
within its per-node cooldown) and sends AT MOST ONE broadcast per cycle
containing all of them, recording the alert timestamp for each after
sending; nothing is sent when none qualify. Format: '⚠️ battery low:
@!<hex> (<name>) <X>%[, @!<hex> (<name>) <Y>%]' — single-node case uses
the same one-entry template. Cooldown semantics unchanged.
2026-09-25 12:51:25 +00:00
kamilkrzywda d0b71e6e91 battery: low-battery alert module (v0.13.0)
BatteryAlertModule + BatteryAlertService wired into app.module like
the weather module. Every BATTERY_CHECK_INTERVAL_MS (default 60 s) the
service scans the node state backing GET /api/nodes and broadcasts a
single-packet '⚠️ @!<8-hex> battery low: <X>% (<name>)' alert for every
node at or below BATTERY_ALERT_THRESHOLD percent (default 20, <= on
purpose) not alerted within BATTERY_ALERT_COOLDOWN_MS (default 6 h,
in-memory map — resets on restart). Sends ride the pinned send channel
via MeshService.sendText like every other bot send.
2026-09-25 12:39:45 +00:00
kamilkrzywda 8ddd91d4b5 mesh: update node batteryLevel from live NODEINFO_APP packets
battery_level was only populated during the initial sync; NodeInfo
frames streamed as NODEINFO_APP (port 4) now refresh deviceMetrics in
the node database so /api/nodes and downstream consumers see current
values between syncs. decodeNodeInfo + PORT_NODEINFO_APP added to the
codec, one branch in the existing packet-handling path.
2026-09-25 12:39:45 +00:00
kamilkrzywda 6ce0b1ae7b MESHTASTIC.md: questions get their answer on the channel (owner standing rule) (#32)
CI / build + unit (push) Successful in 20s
2026-09-25 14:09:10 +02:00
kamilkrzywda 15d93ec2a4 Optional replyId on send (API + MCP) — protocol-level threaded replies (#31)
CI / build + unit (push) Successful in 33s
2026-09-25 13:53:47 +02:00
kamilkrzywda 0909fbc6e2 MESHTASTIC.md: back to channel-only replies (no DMs) (#30)
CI / build + unit (push) Successful in 33s
2026-09-25 13:00:05 +02:00
kamilkrzywda b8ae5d39f7 MESHTASTIC.md: reply to senders as DMs; always respond before starting work (#29)
CI / build + unit (push) Successful in 33s
2026-09-25 12:52:11 +02:00
24 changed files with 1927 additions and 422 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+59
View File
@@ -1,5 +1,64 @@
# Changelog
## 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`.
- Tests: 96 → 102 specs — ring records on a gated forward (shape + newest-first via `forwarded()`), no record on channel mismatch / self-echo / unwired notifier, injected messages count when they pass the gate (`from` omitted when absent), cap at 100 with oldest evicted.
- Docs: README endpoint bullet + curl example; AGENTS.md endpoints list kept in sync with the controller.
## v0.15.0 — 2026-09-25 15:40
- New `POST /api/respond` endpoint and MCP `respond(messageId, content)` tool: reply to an earlier message by packet id with automatic routing — the caller never thinks about where it goes. 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 own 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. Returns `{ id }`; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down; MCP tool errors carry the same messages.
- Captured rx history records now persist their destination (`to: pkt.to`) — no schema change needed: the `to_node` column already existed for tx rows and flush/seed already round-tripped it, so captured packets simply fill it in too. This is what lets a reply tell a DM from a broadcast original.
- The opencode notifier wrapper now includes the packet id: msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` — the id is the rx packet's id, or the generated record id for injected messages; the `from node` part is still omitted when absent.
- Tests: 88 → 96 specs. New: captured-rx row persists `to_node`; six `respond` service specs (rx DM → directed send back to sender on the original channel with wire-level replyId, broadcast original → broadcast reply, tx record → its destination/channel, SQLite fallback when the record is outside the in-memory buffer, 404 memory+DB miss, 400 validation matrix); MCP `respond` wiring spec (routing + unknown-id/out-of-range/empty-content tool errors) and the tool list grows to ten.
- Docs: README gains the endpoint bullet, a curl example, the MCP tool entry and the updated notifier wrapper format; AGENTS.md endpoints/MCP/notifier lines kept in sync with the controller.
## v0.14.0 — 2026-09-25 14:11
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
- Ported forwarder behavior 1:1 with new env names: `OPENCODE_API` (default `http://172.24.0.1:9898` — opencode binds 0.0.0.0 on the host and is reachable from the bot container over dev_net; the old forwarder used 127.0.0.1 only because it ran with `network_mode: host`), `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), `OPENCODE_SESSION` (empty = discover the most recently updated session per request; non-empty = pin that session and skip discovery), `OPENCODE_USER` / `OPENCODE_PASS` (HTTP Basic auth on every call). Missing/empty credentials disable the notifier instead of failing: one warn at construction, `notify()` is a no-op — the app keeps running.
- Workspace/session resolution per request (no caching — messages are rare): `GET {api}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH`, fallback name `mesh-bot`, status `ready` preferred when several match; then, unless pinned, `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry when none carry it). The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (em-dash, not a hyphen; the `from node` part omitted when there is no sender).
- Timeout semantics preserved: every call has a 15 s timeout (`AbortSignal.timeout(15000)`); a read timeout on the final message POST means DELIVERED — opencode accepts immediately but holds the response until the agent turn finishes — so it is treated as success and never retried. Workspace/session GET failures are ordinary retryable failures: backoff [2 s, 10 s, 30 s] (the constant moved here from `mesh.service.ts`), up to 4 attempts total, final failure logs `opencode notify dropped after N attempts`.
- `MeshService` loses `notifyUrl`, `postNotify()` and `NOTIFY_RETRY_DELAYS_MS`; the constructor gains an optional second parameter (`db?, opencode?`) so all existing direct constructions in specs keep working — without a notifier wired, `notifyRx()` is a no-op.
- `docker-compose.yml`: forwarder service block deleted; bot environment drops `MESH_NOTIFY_URL`, keeps `MESH_NOTIFY_CHANNEL`, and passes the five `OPENCODE_*` vars via `${VAR:-default}` interpolation from `.env`. Local `.env` (gitignored) renamed `FORWARDER_USER/PASS/SESSION` → `OPENCODE_USER/PASS/SESSION` (values unchanged, session pin kept), added `OPENCODE_API=http://172.24.0.1:9898`, dropped the `MESH_NOTIFY_URL=` line.
- Tests: new `src/opencode/opencode.service.spec.ts` (12 specs — exact wrapper text with/without `from` incl. em-dash and Basic auth header, workspace path match / name fallback / ready preference, session pin vs discovery-by-max-time.updated vs first-entry fallback, POST timeout = delivered success with no retry, GET failure retried then succeeds, give up after 4 attempts with the dropped log, disabled-when-no-creds no-op + construction warn). `mesh.service.spec.ts` notify block rewritten around a mocked `OpencodeService`: channel gate (default 3), off-channel skip, injected channel-0 case incl. `from` passthrough as undefined, self-echo skip for rx and injected; the ten webhook-specific tests (URL/body/retry/HTTP-error/give-up) are gone — retry semantics now live in the opencode spec.
- Docs: README "Notify forwarder" section replaced by "Opencode notifier", env-var list updated (`MESH_NOTIFY_URL` bullet removed, `OPENCODE_*` documented); AGENTS.md webhook paragraph rewritten for the built-in notifier; MESHTASTIC.md notification rule updated. All FORWARDER_* mentions removed from tracked files (CHANGELOG history keeps them as-is).
## v0.13.1 — 2026-09-25 13:02
- Battery-low alerts now ride channel "testowy" (index 2) instead of the pinned send channel ("test", index 3): `BatteryAlertService` reads new env `BATTERY_ALERT_CHANNEL` at construction (default `2`, same pattern as its other env config) and passes it through the existing `MeshService.sendText(to, text, replyId?, channel?)` path — which gained an optional trailing `channel` argument defaulting to the pinned `MESH_SEND_CHANNEL`, so every other sender (API, MCP, weather) is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions are unchanged.
- Tests: battery specs now assert the send carries channel index 2 by default plus a `BATTERY_ALERT_CHANNEL` override case; the end-to-end case asserts the alert frame rides channel 2 while the pinned send channel stays at its default 3. All pre-existing specs stay green.
## v0.13.0 — 2026-09-25 12:34
- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
- Alert format: `⚠️ battery low: @!<8-hex-lowercase-node-num> (<longName or shortName>) <X>%` with additional nodes appended as `, @!<hex> (<name>) <Y>%` (the single-node case is the same template with one entry) — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), entries kept tight so common cases stay near the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when a node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport.
- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map<nodeNum, lastAlertEpochMs>` — only nodes outside their window are included in a batch and the alert timestamp is recorded for every node in it after sending, so a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy).
- Live battery updates: `batteryLevel` was previously only populated from NodeInfo frames during the initial sync. Live NODEINFO_APP packets (port 4, serialized meshtastic.NodeInfo in Data.payload) now update `deviceMetrics` in the node database too — new `decodeNodeInfo()` + `PORT_NODEINFO_APP` in codec.ts, one branch in the existing packet-handling path — so alerts and `/api/nodes` reflect current values between syncs. Unknown nodes get a stub entry when they report metrics live; malformed payloads are skipped like every other port.
- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, batched message construction incl. multi-node append, name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, all qualifying nodes merged into exactly one broadcast, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers, in-cooldown node excluded from a batch while an out-of-window node still triggers the single message) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green.
## v0.12.8 — 2026-09-25 12:04
- Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green.
## v0.12.7 — 2026-09-25 11:40
- `POST /api/send` and the MCP `send_message` tool now accept an optional `replyId` (integer in 1..0xffffffff) referencing an earlier message's packet id from `GET /api/messages` / `get_messages`. When set, the recipient app renders the sent text as a threaded reply/quote of that packet. The value rides Meshtastic's protocol-level reply mechanism: `Data.reply_id` (field 7) on the TEXT_MESSAGE_APP payload.
- `buildTextToRadio(to, text, channelIndex, replyId?)` in `src/mesh/codec.ts` gained the optional param and sets **camelCase** `replyId` on the Data value (`replyId ?? 0`). The JS protobufs package silently drops snake_case `reply_id`, so camelCase is mandatory; a byte-level spec asserts wire tag `0x3d` (field 7, fixed32) appears iff `replyId` was given.
- `MeshService.sendText(to, text, replyId?)` passes the value through to the codec. REST validation rejects non-integers and out-of-range values with a 400; MCP validates via zod (`z.number().int().min(1).max(0xffffffff)`). Omitting it leaves the field unset (proto3 default 0, not serialized) — existing callers are unaffected.
- New specs: codec byte-level `0x3d` presence/absence check and a service outbound-capture case asserting `Data.replyId === <id>` when given and `0` when omitted. All 62 pre-existing tests stay green (64 total).
- Docs: README endpoint + MCP tool lines, AGENTS.md API section, and the controller's `@ApiOperation` description now document the optional `replyId`.
## v0.12.6 — 2026-09-25 10:55
- Docs (MESHTASTIC.md response protocol): reverted the v0.12.5 reply rule per owner preference ("we will talk on that channel") — replies to messages from a specific node now go back **on the same channel as a broadcast** (no `to`), no direct messages; broadcasts are used for both replies and general announcements such as the hourly weather report. The always-respond-first bullet (**ALWAYS respond ... BEFORE starting any work it triggers**) and every other rule are unchanged. No code changes — `POST /api/send` and the MCP send tool already accept an omitted `to` (broadcast).
## v0.12.5 — 2026-09-25 10:45
- Docs (MESHTASTIC.md response protocol): replies to messages from a specific node now go back **as a direct message to that sender's node number** (`to` = the sender's node num), so they surface in the direct conversation thread ("flagged as reply") instead of as new channel posts. Broadcasts (no `to`) are reserved for general announcements such as the hourly weather report. No code changes — `POST /api/send` and the MCP send tool already accept a `to` target; all other rules (owner authority 2594370888, language, confirm-first-then-work, channel pinning, self-echo note, NO_RESPONSE quirk) unchanged.
- MESHTASTIC.md confirm-first rule made explicit and mandatory: **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** (acknowledge first, then work) — the previous "first respond/confirm, then start the actual work" wording was not explicit enough and got violated in practice.
## v0.12.4 — 2026-09-25 10:17
- Notify forwarder (`ops/forwarder`) now resolves the opencode workspace + session dynamically per request instead of hardcoding them as env defaults: `GET {FORWARDER_API}/api/workspaces` → match by `path == FORWARDER_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. Fixes lost user messages after an opencode host restart regenerated the workspace UUID (the old hardcoded base URL 404'd on every notify POST).
+4 -3
View File
@@ -14,8 +14,9 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
## Response protocol
- When doing any action triggered by a chat message: **first respond/confirm on the channel, then start the actual work** — so the user gets the fastest possible response.
- Replies to messages received on a channel go back **on that same channel as a broadcast** (no `to`), not as a DM — unless the sender explicitly asks for a private reply.
- **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** — acknowledge first, then work. This is mandatory for every action triggered by a chat message; the confirmation goes out before you start (so the user gets the fastest possible response).
- Replies to messages from a specific node go back **on the same channel as a broadcast** (no `to`) — do not send direct messages; per owner preference, all conversation happens on the channel. Broadcasts are used for both replies and general announcements such as the hourly weather report.
- **Questions are answered on the channel** — every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session.
## Channels & sending
@@ -24,4 +25,4 @@ 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) triggers an outbound webhook POST `{text, from}` to `MESH_NOTIFY_URL` (host forwarder :4097 → opencode session). Messages from our own node are never notified (self-echo filter, v0.11.2).
- 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).
+17 -12
View File
@@ -26,11 +26,13 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
All API endpoints live under the `/api` prefix:
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) 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
- `POST /api/respond` — body `{ "messageId": <packet id>, "content": "..." }`, returns `{ id }`. Reply to an earlier message by packet id (from `GET /api/messages`) with automatic routing: 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 are redacted; empty until the initial sync completes)
- `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 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
- `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/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
@@ -42,10 +44,12 @@ Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
curl http://localhost:3000/api/status
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
curl -X POST http://localhost:3000/api/respond -H 'content-type: application/json' -d '{"messageId":123456789,"content":"on my way"}'
curl http://localhost:3000/api/channels
curl http://localhost:3000/api/device
curl http://localhost:3000/api/messages
curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}'
curl http://localhost:3000/api/forwarded
curl http://localhost:3000/api/telemetry
curl http://localhost:3000/api/nodes
curl http://localhost:3000/api/nodes/kamil
@@ -64,10 +68,12 @@ channel. Override with `MESH_SEND_CHANNEL`.
The bot also exposes an MCP (Model Context Protocol) endpoint at
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
endpoints — no new logic, same channel pinning:
endpoints — no new logic; sends ride the pinned channel like their REST
counterparts (`respond` rides the original message's channel):
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors
- `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors
- `respond(messageId, content)` — reply to an earlier message by packet id (from `get_messages`) with automatic routing: 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` (threaded quote); returns `{ id }`; invalid args or an unknown id are tool errors
- `list_channels` — device channels (keys redacted)
- `list_nodes` — known nodes as lean summaries
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
@@ -97,16 +103,16 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo
- `WEATHER_CITY` (default `"Lublin"`) — city name in the report line
## Notify forwarder
## Opencode notifier
Second compose service (`ops/forwarder`, `python:3.12-slim`, stdlib only): receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the forwarder resolves it dynamically per request via the opencode API: `GET /api/workspaces` → match by `path == FORWARDER_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. Runs with `network_mode: host` — it listens on the host's `0.0.0.0:4097`, which is why `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` works from the bot container, and reaches opencode at `127.0.0.1:9898`. `restart: unless-stopped`; no Traefik labels (host networking).
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.
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
- `FORWARDER_API` (default `http://127.0.0.1:9898`) — opencode API base URL
- `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match)
- `FORWARDER_SESSION` (optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per request
- `FORWARDER_USER` / `FORWARDER_PASS` — Basic auth credentials; **required**, the container exits on startup if either is missing
- `OPENCODE_API` (default `http://172.24.0.1:9898`) — opencode API base URL as seen from the bot container (dev_net bridge → host; opencode binds 0.0.0.0 on the host)
- `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match)
- `OPENCODE_SESSION` (optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per request
- `OPENCODE_USER` / `OPENCODE_PASS` — Basic auth credentials; missing/empty **disables** the notifier (one startup log line, no-op) instead of failing
## Env vars
@@ -114,8 +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_URL` — outbound notification webhook URL; when set (non-empty), an inbound rx text message arriving on channel `MESH_NOTIFY_CHANNEL` fires a fire-and-forget HTTP POST with JSON body `{text, from}` (`from` omitted when absent). Failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Synthetic 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). Unset or empty = disabled (no fetch at all)
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index that triggers the notify webhook
- `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_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
+5 -12
View File
@@ -6,8 +6,12 @@ services:
MESH_PORT: '4403'
MESH_DB_PATH: ./data/mesh-bot.db
MESH_POLL_MS: '1000'
MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-}
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
OPENCODE_API: ${OPENCODE_API:-http://172.24.0.1:9898}
OPENCODE_WORKSPACE_PATH: ${OPENCODE_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
OPENCODE_SESSION: ${OPENCODE_SESSION:-}
OPENCODE_USER: ${OPENCODE_USER:-}
OPENCODE_PASS: ${OPENCODE_PASS:-}
WEATHER_CRON: ${WEATHER_CRON:-}
volumes:
- mesh_data:/app/data
@@ -19,17 +23,6 @@ services:
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
- 'traefik.http.routers.mesh-bot.entrypoints=web'
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
forwarder:
build: ./ops/forwarder
network_mode: host
environment:
FORWARDER_API: ${FORWARDER_API:-http://127.0.0.1:9898}
FORWARDER_WORKSPACE_PATH: ${FORWARDER_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
FORWARDER_SESSION: ${FORWARDER_SESSION:-}
FORWARDER_USER: ${FORWARDER_USER:-}
FORWARDER_PASS: ${FORWARDER_PASS:-}
restart: unless-stopped
volumes:
mesh_data:
-4
View File
@@ -1,4 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY forwarder.py .
CMD ["python3", "forwarder.py"]
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth).
The opencode workspace UUID is regenerated on host restarts, so the target workspace + session are
resolved dynamically per request via the opencode API (no caching — messages are rare): list
workspaces, pick the mesh-bot one by path (fallback: name), then its most recently updated session.
Note on semantics: the opencode API accepts a session message immediately but holds the HTTP
response until the agent turn completes — so a read timeout here means DELIVERED, not failed.
"""
import json, base64, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
API = os.environ.get("FORWARDER_API", "http://127.0.0.1:9898")
WORKSPACE_PATH = os.environ.get("FORWARDER_WORKSPACE_PATH", "/home/krzyw/projects/sandbox/mesh-bot")
SESSION_OVERRIDE = os.environ.get("FORWARDER_SESSION", "") # when set, skips session discovery
USER = os.environ["FORWARDER_USER"] # required; fail fast if missing
PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing
def _auth_headers():
return {"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()}
def _get_json(url):
req = urllib.request.Request(url, headers=_auth_headers())
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
except Exception as e:
raise RuntimeError(f"opencode API unreachable at {url}: {e!r}") from None
def resolve_target():
"""Resolve (base_url, session_id) fresh per request; raises RuntimeError with a clear reason."""
workspaces = _get_json(API + "/api/workspaces")
matches = [w for w in workspaces if w.get("path") == WORKSPACE_PATH] or \
[w for w in workspaces if w.get("name") == "mesh-bot"]
pool = [w for w in matches if w.get("status") == "ready"] or matches
if not pool:
raise RuntimeError(f"workspace not found: path={WORKSPACE_PATH} name=mesh-bot")
base = API + pool[0]["proxyPath"]
sid = SESSION_OVERRIDE
if not sid:
sessions = _get_json(base + "/session")
if not sessions:
raise RuntimeError(f"no sessions in workspace {pool[0]['id']} ({WORKSPACE_PATH})")
def updated(s):
return (s.get("time") or {}).get("updated") or 0
sid = max(sessions, key=updated)["id"] if any(updated(s) for s in sessions) else sessions[0]["id"]
return base, sid
class H(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/notify":
self.send_response(404); self.end_headers(); return
n = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(n) or b"{}")
text, frm = body.get("text"), body.get("from")
msg = 'New mesh message' + (f' from node {frm}' if frm is not None else '') + f': "{text}" — please react to it.'
base, sid = resolve_target() # fresh per request; RuntimeError on API/workspace/session failure
req = urllib.request.Request(
f"{base}/session/{sid}/message",
data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(),
headers={"Content-Type": "application/json", **_auth_headers()},
method="POST")
try:
r = urllib.request.urlopen(req, timeout=15)
code = r.status # headers received => opencode accepted the message
try: out = r.read(200).decode(errors='replace')
except Exception as e2: out = f'(body read failed after delivery: {e2!r})'
print(f"forwarded to opencode session {sid}: HTTP {code} {out[:150]!r}", flush=True)
self._reply(code if 200 <= code < 300 else 502, b'{"ok":true}' if 200 <= code < 300 else str(code).encode())
except TimeoutError:
# request was sent; opencode holds the response until the agent turn finishes => delivered
print(f"forwarded to opencode session {sid}: accepted (response still pending after 15 s)", flush=True)
self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}')
except RuntimeError as e:
# resolve_target() failure modes: API unreachable / workspace not found / no sessions in workspace
print(f"target resolution failed: {e!r}", flush=True)
self._reply(502, repr(e).encode())
except Exception as e:
print(f"opencode call failed: {e!r}", flush=True)
self._reply(502, repr(e).encode())
def _reply(self, code, payload):
try:
self.send_response(code); self.end_headers(); self.wfile.write(payload)
except (BrokenPipeError, ConnectionResetError):
pass # client went away; delivery state already logged above
def log_message(self, fmt, *args):
print("forwarder:", fmt % args, flush=True)
HTTPServer(("0.0.0.0", 4097), H).serve_forever()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.12.4",
"version": "0.16.0",
"description": "",
"author": "",
"private": true,
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { BatteryAlertModule } from './battery/battery.module.js';
import { MeshModule } from './mesh/mesh.module.js';
import { WeatherModule } from './weather/weather.module.js';
@Module({
imports: [MeshModule, WeatherModule],
imports: [MeshModule, WeatherModule, BatteryAlertModule],
})
export class AppModule {}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { MeshModule } from '../mesh/mesh.module.js';
import { BatteryAlertService } from './battery.service.js';
@Module({
imports: [ScheduleModule.forRoot(), MeshModule],
providers: [BatteryAlertService],
exports: [BatteryAlertService],
})
export class BatteryAlertModule {}
+287
View File
@@ -0,0 +1,287 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js';
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import {
BatteryAlertService,
formatAlert,
mention,
} from './battery.service.js';
function node(
num: number,
batteryLevel?: number,
longName?: string,
shortName?: string,
): NodeSummary {
const n: NodeSummary = { num, lastHeard: null };
if (batteryLevel !== undefined) n.batteryLevel = batteryLevel;
if (longName) n.longName = longName;
if (shortName) n.shortName = shortName;
return n;
}
function makeService(
nodes: NodeSummary[],
sendText?: ReturnType<typeof vi.fn>,
): BatteryAlertService {
const mesh = { nodes: async () => nodes, sendText } as unknown as MeshService;
return new BatteryAlertService(mesh);
}
beforeEach(() => {
// defaults under test: threshold 20, cooldown 6 h, channel 2 ("testowy")
delete process.env.BATTERY_ALERT_THRESHOLD;
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
delete process.env.BATTERY_ALERT_CHANNEL;
});
describe('mention', () => {
it('zero-pads to 8 chars and lowercases the hex', () => {
expect(mention(0xabc)).toBe('@!00000abc'); // padded
expect(mention(0xffffffff)).toBe('@!ffffffff'); // already 8 chars — no padding needed
expect(mention(0xdeadbeef)).toBe('@!deadbeef'); // lowercase
});
});
describe('formatAlert', () => {
it('builds the single-node message with longName', () => {
expect(formatAlert([node(0x1234abcd, 15, 'Kamil SP8MK/P')])).toBe(
'⚠️ battery low: @!1234abcd (Kamil SP8MK/P) 15%',
);
});
it('appends additional nodes as ", <entry>" in one line', () => {
expect(
formatAlert([node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')]),
).toBe('⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%');
});
it('falls back to shortName and omits the name part when neither is known', () => {
expect(formatAlert([node(0xabc, 9, undefined, 'KK')])).toBe(
'⚠️ battery low: @!00000abc (KK) 9%',
);
expect(formatAlert([node(0xabc, 9)])).toBe('⚠️ battery low: @!00000abc 9%');
});
it('stays under the ~190-char single-packet limit', () => {
const msg = formatAlert([node(0xffffffff, 5, 'A'.repeat(64))]);
expect(msg.length).toBeLessThan(190);
});
});
describe('BatteryAlertService check threshold', () => {
it('alerts at and below the default threshold (20) but not above', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 19), node(2, 20), node(3, 21)], sendText);
await svc.check();
// <= choice: exactly 20 qualifies too — both in ONE batched message, on the default channel 2
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
undefined,
2,
);
});
it('honors a custom BATTERY_ALERT_THRESHOLD', async () => {
process.env.BATTERY_ALERT_THRESHOLD = '50';
const sendText = vi.fn(async (..._args: unknown[]) => 1);
try {
const svc = makeService([node(1, 50), node(2, 51)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 50%',
undefined,
2,
);
} finally {
delete process.env.BATTERY_ALERT_THRESHOLD;
}
});
it('skips nodes without a reported battery level', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1), node(2, 87)], sendText); // no battery / healthy
await svc.check();
expect(sendText).not.toHaveBeenCalled();
});
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService(
[node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')],
sendText,
);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1); // never one message per node
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
undefined,
2, // default channel "testowy" — not the pinned send channel (3)
]);
});
it('sends on a custom BATTERY_ALERT_CHANNEL when set', async () => {
process.env.BATTERY_ALERT_CHANNEL = '5';
const sendText = vi.fn(async (..._args: unknown[]) => 1);
try {
const svc = makeService([node(0xabc, 5)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000abc 5%',
undefined,
5,
]);
} finally {
delete process.env.BATTERY_ALERT_CHANNEL;
}
});
it('never throws when a send fails', async () => {
const svc = makeService(
[node(1, 5)],
vi.fn(async (..._args: unknown[]) => {
throw new Error('bridge down');
}),
);
await expect(svc.check()).resolves.toBeUndefined();
});
});
describe('BatteryAlertService check cooldown', () => {
it('suppresses re-alerts within the window and allows them after expiry (default 6 h)', async () => {
vi.useFakeTimers();
try {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(0xabc, 5)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1); // first alert
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1); // immediate re-check — suppressed
vi.advanceTimersByTime(6 * 3_600_000 - 1);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1); // still inside the window
vi.advanceTimersByTime(1);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(2); // cooldown elapsed — re-alerted
} finally {
vi.useRealTimers();
}
});
it('excludes a node inside its cooldown from the batch while another outside it still triggers one message', async () => {
vi.useFakeTimers();
try {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
// only B is known at first — alert it, then A appears while B is still in cooldown
const nodes = [node(0xdef, 3)];
const svc = makeService(nodes, sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText.mock.calls[0][1]).toBe('⚠️ battery low: @!00000def 3%');
nodes.push(node(0xabc, 5)); // A is due, B is inside its window
await svc.check();
expect(sendText).toHaveBeenCalledTimes(2); // one message — only A
expect(sendText.mock.calls[1][1]).toBe('⚠️ battery low: @!00000abc 5%');
vi.advanceTimersByTime(6 * 3_600_000); // B's cooldown expires too
await svc.check();
expect(sendText).toHaveBeenCalledTimes(3); // both due again — one batched message with both
expect(sendText.mock.calls[2][1]).toBe(
'⚠️ battery low: @!00000def 3%, @!00000abc 5%',
);
} finally {
vi.useRealTimers();
}
});
});
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
const mesh = new RealMeshService();
try {
mesh.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && mesh.status().connected,
);
bridge.sockets[0].write(
nodeInfoFrame(0xabc, {
user: { id: '!a', longName: 'Alpha' },
deviceMetrics: { batteryLevel: 5 },
}),
);
await waitFor(async () => (await mesh.nodes()).length === 1);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const svc = new BatteryAlertService(mesh);
await svc.check();
const readOutbound = (): P.Mesh.MeshPacket[] => {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
// outbound starts with the want_config_id frame — collect only text packets, in order
return packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet')
return [tr.payloadVariant.value];
return [];
});
};
await waitFor(() => readOutbound().length === 1);
const pkt = readOutbound()[0];
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
'⚠️ battery low: @!00000abc (Alpha) 5%',
);
} finally {
delete process.env.MESH_HOST;
delete process.env.MESH_PORT;
delete process.env.MESH_SEND_CHANNEL;
mesh.onModuleDestroy();
bridge.close();
}
});
});
+80
View File
@@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import type { NodeSummary } from '../mesh/mesh.service.js';
import { MeshService } from '../mesh/mesh.service.js';
/** Meshtastic mention of a node: `@!` + 8-char zero-padded lowercase hex, e.g. `@!09a2b3c4`. */
export function mention(nodeNum: number): string {
return `@!${nodeNum.toString(16).padStart(8, '0')}`;
}
/** One tight entry per node, e.g. `@!09a2b3c4 (Kamil SP8MK/P) 15%`. */
export function formatEntry(node: NodeSummary): string {
// no name reported → omit the paren part rather than invent one
const name = node.longName ?? node.shortName;
return `${mention(node.num)}${name ? ` (${name})` : ''} ${node.batteryLevel}%`;
}
/** One short single-packet alert line for a whole batch, e.g. `⚠️ battery low: @!09a2b3c4 (Kamil SP8MK/P) 15%, @!0def5678 (KK) 9%` — entries kept tight so common cases stay near the ~190-char single-packet limit. */
export function formatAlert(nodes: NodeSummary[]): string {
return `⚠️ battery low: ${nodes.map(formatEntry).join(', ')}`;
}
@Injectable()
export class BatteryAlertService {
private readonly logger = new Logger(BatteryAlertService.name);
// read at construction, like MeshService's env config
private readonly threshold = parseInt(
process.env.BATTERY_ALERT_THRESHOLD ?? '20',
10,
);
private readonly cooldownMs = parseInt(
process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h
10,
);
// alerts ride "testowy" (index 2), not the pinned send channel — owner decision
private readonly alertChannel = parseInt(
process.env.BATTERY_ALERT_CHANNEL ?? '2',
10,
);
// ponytail: in-memory cooldown map — resets on process restart (a node may get one duplicate alert after a deploy)
private lastAlertAt = new Map<number, number>();
constructor(private readonly mesh: MeshService) {}
/** Interval scan of the same node state that backs GET /api/nodes; collects every low-battery node not recently alerted and sends AT MOST ONE broadcast per cycle containing all of them. Never throws — a failed check must not crash the app. */
@Interval(parseInt(process.env.BATTERY_CHECK_INTERVAL_MS ?? '60000', 10))
async check(): Promise<void> {
try {
const now = Date.now();
const due = (await this.mesh.nodes()).filter((n) =>
this.shouldAlert(n, now),
);
if (due.length === 0) return; // nothing to report — send nothing
await this.alert(due);
} catch (err) {
this.logger.warn(`[battery] check failed: ${(err as Error).message}`);
}
}
private shouldAlert(node: NodeSummary, now: number): boolean {
if (node.batteryLevel === undefined) return false; // no battery reported — skip
// <= on purpose: a node at exactly the threshold is already "low" (spec allowed either < or <=; tests pin this choice)
if (!(node.batteryLevel <= this.threshold)) return false;
const last = this.lastAlertAt.get(node.num);
return last === undefined || now - last >= this.cooldownMs;
}
private async alert(nodes: NodeSummary[]): Promise<void> {
// one broadcast per cycle for the whole batch — never one message per node; rides BATTERY_ALERT_CHANNEL ("testowy", default index 2) via the same MeshService.sendText path as every other bot send
await this.mesh.sendText(
BROADCAST_ADDR,
formatAlert(nodes),
undefined,
this.alertChannel,
);
const now = Date.now();
for (const n of nodes) this.lastAlertAt.set(n.num, now);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { MeshService } from '../mesh/mesh.service.js';
import { BatteryAlertModule } from './battery.module.js';
import { BatteryAlertService } from './battery.service.js';
describe('DI wiring (module graph)', () => {
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
it('resolves BatteryAlertService 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: [BatteryAlertModule],
}).compile();
expect(moduleRef.get(BatteryAlertService)).toBeInstanceOf(
BatteryAlertService,
);
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
});
});
+25 -1
View File
@@ -1,5 +1,7 @@
import { fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { describe, expect, it } from 'vitest';
import { extractFrames, frame } from './codec.js';
import { buildTextToRadio, extractFrames, frame } from './codec.js';
const F = (payload: number[]) => [
0x94,
@@ -78,3 +80,25 @@ describe('frame', () => {
expect(rest.length).toBe(0);
});
});
// re-serialize just the nested Data message so its wire bytes are inspectable without the random packet id interfering
function dataBytes(framed: Uint8Array): Uint8Array {
const { packets } = extractFrames(framed); // buildTextToRadio returns framed bytes
expect(packets).toHaveLength(1);
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
if (tr.payloadVariant?.case !== 'packet') throw new Error('not a packet');
const pv = tr.payloadVariant.value.payloadVariant;
if (pv?.case !== 'decoded') throw new Error('not decoded');
return toBinary(P.Mesh.DataSchema, pv.value);
}
describe('buildTextToRadio', () => {
it('writes wire tag 0x3d (Data.reply_id, field 7) iff replyId is given', async () => {
const withReply = await buildTextToRadio(42, 'hi', 3, 42);
expect(Array.from(dataBytes(withReply.bytes))).toContain(0x3d);
// omitting it must leave the field off the wire (proto3 default 0) — snake_case reply_id would be silently dropped here
const plain = await buildTextToRadio(42, 'hi', 3);
expect(Array.from(dataBytes(plain.bytes))).not.toContain(0x3d);
});
});
+13
View File
@@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff;
export const PORT_TEXT_MESSAGE_APP = 1;
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
export const PORT_POSITION_APP = 3;
// verified against installed @meshtastic/protobufs Portnums.PortNum.NODEINFO_APP
export const PORT_NODEINFO_APP = 4;
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
export const PORT_ROUTING_APP = 5;
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
@@ -75,6 +77,14 @@ export async function decodePosition(
return fromBinary(P.Mesh.PositionSchema, bytes);
}
/** NODEINFO_APP packets carry a serialized meshtastic.NodeInfo in Data.payload (live node info; deviceMetrics carries battery_level). */
export async function decodeNodeInfo(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Mesh.NodeInfo> {
const P = await loadProto();
return fromBinary(P.Mesh.NodeInfoSchema, bytes);
}
/** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
export async function decodeRouting(
bytes: Uint8Array,
@@ -126,6 +136,7 @@ export async function buildTextToRadio(
to: number,
text: string,
channelIndex: number,
replyId?: number,
): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto();
const id = randomPacketId();
@@ -140,6 +151,8 @@ export async function buildTextToRadio(
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
wantResponse: true,
// camelCase on purpose — the JS protobufs package silently drops snake_case reply_id; 0 = not a reply (proto3 default, omitted from wire)
replyId: replyId ?? 0,
},
},
});
+28
View File
@@ -148,6 +148,34 @@ export function telemetryPacketFrame(
);
}
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
export function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
+62 -1
View File
@@ -11,6 +11,7 @@ import {
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
textPacketFrame,
waitFor,
} from './fake-bridge.js';
import { createMeshMcpServer } from './mcp.controller.js';
@@ -67,7 +68,7 @@ async function connectMcp(svc: MeshService): Promise<{
}
describe('MCP server tools', () => {
it('lists all nine tools with descriptions and input schemas', async () => {
it('lists all ten tools with descriptions and input schemas', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
@@ -82,6 +83,7 @@ describe('MCP server tools', () => {
'list_channels',
'list_nodes',
'mesh_status',
'respond',
'send_message',
]);
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
@@ -91,6 +93,7 @@ describe('MCP server tools', () => {
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
'text',
'to',
'replyId',
]);
const inject = tools.find((t) => t.name === 'inject_message')!;
@@ -99,6 +102,13 @@ describe('MCP server tools', () => {
'text',
'from',
]);
const respond = tools.find((t) => t.name === 'respond')!;
expect(respond.inputSchema.required).toEqual(['messageId', 'content']);
expect(Object.keys(respond.inputSchema.properties ?? {})).toEqual([
'messageId',
'content',
]);
} finally {
await mcp.close();
}
@@ -195,6 +205,57 @@ describe('MCP server tools', () => {
}
});
it('respond routes the reply automatically and surfaces failures as tool errors', async () => {
const env = await connectedService();
try {
// rx DM from KAMIL on channel 3 (packet id 1)
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
await waitFor(() => env.svc.messages().length === 1);
const mcp = await connectMcp(env.svc);
try {
const r1 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 1, content: 'pong' },
});
expect(r1.isError).not.toBe(true);
const reply = JSON.parse(toolText(r1)) as { id: number };
expect(reply.id).toBeGreaterThan(0);
// DM back to the original sender on the original channel
expect(env.svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: 'pong',
channel: 3,
});
const r2 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 999, content: 'x' },
});
expect(r2.isError).toBe(true);
expect(toolText(r2)).toContain('no message with id 999');
const r3 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 0, content: 'x' },
});
expect(r3.isError).toBe(true); // out-of-range rejected before the service sees it
const r4 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 1, content: '' },
});
expect(r4.isError).toBe(true);
expect(toolText(r4)).toContain('content must be a non-empty string');
} finally {
await mcp.close();
}
} finally {
env.close();
}
});
it('inject_message records an rx entry without sending over RF', async () => {
const env = await connectedService();
try {
+23 -4
View File
@@ -33,11 +33,30 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
server.registerTool(
'send_message',
{
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
inputSchema: { text: z.string(), to: z.string().optional() },
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error. replyId (optional, integer in 1..0xffffffff): the packet id of an earlier message from GET /api/messages or get_messages — when set, the recipient app renders this message as a threaded reply/quote of that packet.${CHANNEL_NOTE}`,
inputSchema: {
text: z.string(),
to: z.string().optional(),
replyId: z.number().int().min(1).max(0xffffffff).optional(),
},
},
async ({ text, to }) =>
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
async ({ text, to, replyId }) =>
jsonResult({
id: await mesh.sendText(await mesh.resolveDest(to), text, replyId),
}),
);
server.registerTool(
'respond',
{
description: `Reply to an earlier message by its packet id (from get_messages); returns { id } — the Meshtastic packet id of the reply. Routing is automatic: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals and injected messages without a sender get a broadcast. The reply always rides the record's channel and carries replyId = messageId (threaded quote in the recipient app). Invalid messageId/content or an unknown id are tool errors.`,
inputSchema: {
messageId: z.number().int().min(1).max(0xffffffff),
content: z.string(),
},
},
async ({ messageId, content }) =>
jsonResult(await mesh.respond(messageId, content)),
);
server.registerTool(
+71 -2
View File
@@ -13,6 +13,7 @@ import {
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js';
import type { DeviceInfo } from './mesh.service.js';
import { MeshService } from './mesh.service.js';
@@ -28,6 +29,17 @@ export class InjectMessageDto {
from?: number;
}
export class RespondDto {
@ApiProperty({
description:
'Packet id of the earlier message (from GET /api/messages) that this one replies to. Integer in 1..0xffffffff.',
example: 456789,
})
messageId!: number;
@ApiProperty({ description: 'Reply text', example: 'on my way' })
content!: string;
}
export class SendTextDto {
@ApiProperty({ description: 'Message text', example: 'hello' })
text!: string;
@@ -38,6 +50,13 @@ export class SendTextDto {
example: '!b',
})
to?: string | number;
@ApiProperty({
required: false,
description:
'Packet id of an earlier message (from GET /api/messages) that this one replies to — the recipient app renders it as a threaded reply/quote. Integer in 1..0xffffffff.',
example: 456789,
})
replyId?: number;
}
// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description)
@@ -70,7 +89,7 @@ export class MeshController {
@ApiOperation({
summary: 'Send a text message over the mesh',
description:
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). Optional replyId (integer in 1..0xffffffff) references an earlier message\'s packet id from GET /api/messages — when set, the recipient app renders this message as a threaded reply/quote of that packet. DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
})
@ApiResponse({
status: 200,
@@ -83,6 +102,15 @@ export class MeshController {
if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string');
}
const replyId = body.replyId;
if (
replyId !== undefined &&
(!Number.isInteger(replyId) || replyId < 1 || replyId > BROADCAST_ADDR)
) {
throw new BadRequestException(
'replyId must be an integer in 1..0xffffffff',
);
}
let to: number;
try {
to = await this.mesh.resolveDest(body.to);
@@ -91,7 +119,25 @@ export class MeshController {
err instanceof Error ? err.message : String(err),
);
}
return { id: await this.mesh.sendText(to, body.text) };
return { id: await this.mesh.sendText(to, body.text, replyId) };
}
@Post('respond')
@ApiOperation({
summary: 'Reply to an earlier message by packet id',
description:
"Routing is automatic — the caller never thinks about where it goes: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals (and 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. Returns { id } with the new packet id.",
})
@ApiResponse({
status: 200,
description: 'Packet queued',
example: { id: 123456789 },
})
@ApiResponse({ status: 400, description: 'Invalid messageId or content' })
@ApiResponse({ status: 404, description: 'No message with the given id' })
@ApiResponse({ status: 503, description: 'Bridge not connected' })
async respond(@Body() body: RespondDto) {
return this.mesh.respond(body?.messageId, body?.content);
}
@Get('channels')
@@ -195,6 +241,29 @@ export class MeshController {
return this.mesh.messages();
}
@Get('forwarded')
@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.',
})
@ApiResponse({
status: 200,
description: 'Forwarded messages newest first',
example: [
{
id: 456789,
from: 987654321,
text: 'ping',
channel: 3,
at: 1758000000000,
},
],
})
forwarded() {
return this.mesh.forwarded();
}
@Post('messages')
@ApiOperation({
summary: 'Inject a synthetic inbound message into the history',
+5 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { OpencodeService } from '../opencode/opencode.service.js';
import { McpController } from './mcp.controller.js';
import { MeshController } from './mesh.controller.js';
import { initMessagesTable, MeshService } from './mesh.service.js';
@@ -18,9 +19,12 @@ function openMessageDb(): DatabaseSync {
@Module({
controllers: [MeshController, McpController],
providers: [
OpencodeService,
{
provide: MeshService,
useFactory: () => new MeshService(openMessageDb()),
useFactory: (opencode: OpencodeService) =>
new MeshService(openMessageDb(), opencode),
inject: [OpencodeService],
},
],
exports: [MeshService],
+568 -213
View File
@@ -1,6 +1,6 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import { Logger } from '@nestjs/common';
import * as P from '@meshtastic/protobufs';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DatabaseSync } from 'node:sqlite';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
@@ -13,8 +13,10 @@ import {
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
textPacketFrame,
waitFor,
} from './fake-bridge.js';
import { OpencodeService } from '../opencode/opencode.service.js';
import { initMessagesTable, MeshService } from './mesh.service.js';
// live position update: POSITION_APP packet with a serialized Position in Data.payload
@@ -44,22 +46,24 @@ function positionPacketFrame(
);
}
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
// live node-info update: NODEINFO_APP packet with a serialized NodeInfo in Data.payload
function nodeInfoPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
fields: {
user?: { id?: string; longName?: string };
deviceMetrics?: { batteryLevel?: number };
},
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
portnum: P.Portnums.PortNum.NODEINFO_APP,
payload: toBinary(
P.Mesh.NodeInfoSchema,
create(P.Mesh.NodeInfoSchema, { num: from, ...fields }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
@@ -296,6 +300,64 @@ describe('MeshService send', () => {
bridge.close();
}
});
it('sets Data.reply_id when replyId is given and leaves it unset otherwise', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
await svc.sendText(42, 'reply send', 42); // replyId = 42
await svc.sendText(43, 'plain send');
const readOutbound = (): P.Mesh.MeshPacket[] => {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
// outbound starts with the want_config_id frame — collect only text packets, in order
return packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet')
return [tr.payloadVariant.value];
return [];
});
};
await waitFor(() => readOutbound().length === 2);
const [replyPkt, plainPkt] = readOutbound();
const replyData =
replyPkt.payloadVariant?.case === 'decoded'
? replyPkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(replyData?.payload ?? new Uint8Array()),
).toBe('reply send');
expect(replyData?.replyId).toBe(42); // threaded reply to packet 42
const plainData =
plainPkt.payloadVariant?.case === 'decoded'
? plainPkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(plainData?.payload ?? new Uint8Array()),
).toBe('plain send');
expect(plainData?.replyId).toBe(0); // omitted → proto3 default, not a reply
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService node database', () => {
@@ -360,6 +422,47 @@ describe('MeshService node database', () => {
}
});
it('updates batteryLevel from live NODEINFO_APP packets (and stubs unknown nodes)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// sync entry with a stale battery, then a live NODEINFO_APP update
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await svc.nodes()).length === 1);
expect((await svc.nodes())[0]?.batteryLevel).toBe(87);
bridge.sockets[0].write(
nodeInfoPacketFrame(KAMIL_NUM, { deviceMetrics: { batteryLevel: 9 } }),
);
await waitFor(async () => (await svc.nodes())[0]?.batteryLevel === 9);
// other fields of the sync entry survive the live update
expect(await svc.node(KAMIL_NUM)).toMatchObject({
longName: 'Kamil SP8MK/P',
shortName: 'KK',
batteryLevel: 9,
});
// unknown node: a NODEINFO_APP with deviceMetrics creates a stub entry
bridge.sockets[0].write(
nodeInfoPacketFrame(555, { deviceMetrics: { batteryLevel: 3 } }),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect((await svc.node(555))?.batteryLevel).toBe(3);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('looks up nodes by exact num, numeric string, exact name and unique substring', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
@@ -465,6 +568,7 @@ describe('MeshService message history', () => {
id: 1,
dir: 'rx',
from: KAMIL_NUM,
to: 42, // destination persisted on rx records (broadcast addr for broadcasts)
text: 'ping',
channel: 2,
at: expect.any(Number),
@@ -563,12 +667,238 @@ describe('MeshService message history', () => {
});
});
describe('MeshService notify webhook', () => {
const fetchMock = vi.fn(async (..._args: unknown[]) => undefined);
// outbound text packets written to the bridge, in order (want_config frames skipped)
function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
return extractFrames(merged).packets.flatMap((p) => {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
return [];
});
}
describe('MeshService respond', () => {
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// rx DM from KAMIL to our node on channel 2 (packet id 1)
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
await waitFor(() => svc.messages().length === 1);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const { id } = await svc.respond(1, 'pong');
expect(id).toBeGreaterThan(0);
// DM back to the original sender on the original channel
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
to: KAMIL_NUM,
text: 'pong',
channel: 2,
status: 'pending',
});
await waitFor(() => outboundPackets(out).length === 1);
const pkt = outboundPackets(out)[0]!;
expect(pkt.to).toBe(KAMIL_NUM);
expect(pkt.channel).toBe(2);
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(data?.replyId).toBe(1); // threaded quote of the original packet
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('replies to a broadcast original as a broadcast on the original channel with replyId = messageId', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// rx broadcast from KAMIL on channel 2 (packet id 1)
bridge.sockets[0].write(
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 2),
);
await waitFor(() => svc.messages().length === 1);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const { id } = await svc.respond(1, 'pong');
expect(id).toBeGreaterThan(0);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: BROADCAST_ADDR, // broadcast original → broadcast reply
text: 'pong',
channel: 2,
});
await waitFor(() => outboundPackets(out).length === 1);
const pkt = outboundPackets(out)[0]!;
expect(pkt.to).toBe(BROADCAST_ADDR);
expect(pkt.channel).toBe(2);
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(data?.replyId).toBe(1);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('replies to a tx record targeting its destination on its channel with replyId = messageId', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const origId = await svc.sendText(42, 'orig'); // tx record: to=42, default channel 3
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const { id } = await svc.respond(origId, 'reply');
expect(id).toBeGreaterThan(0);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: 42, // the tx record's own destination
text: 'reply',
channel: 3,
});
// the orig send may share a chunk with the reply — take the newest packet
await waitFor(() => outboundPackets(out).length >= 1);
const pkts = outboundPackets(out);
const pkt = pkts[pkts.length - 1]!;
expect(pkt.to).toBe(42);
const data =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(data?.replyId).toBe(origId);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('throws NotFoundException when no message has the given id (memory or SQLite)', async () => {
const svc = new MeshService();
await expect(svc.respond(999, 'hi')).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(svc.respond(999, 'hi')).rejects.toThrow(
'no message with id 999',
);
const db = new DatabaseSync(':memory:'); // empty table — the DB lookup path also misses
initMessagesTable(db);
const svc2 = new MeshService(db);
await expect(svc2.respond(999, 'hi')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('falls back to SQLite when the record is not in the in-memory buffer', async () => {
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
// 150 rows — onModuleInit seeds only the newest 100 into the buffer (ids 2050..2149)
const ins = db.prepare(
'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (let i = 0; i < 150; i++)
ins.run(
2000 + i,
'rx',
42,
KAMIL_NUM,
`old-${i}`,
2,
1_700_000_000_000 + i * 1000,
null,
null,
);
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService(db);
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// id 2000 is in SQLite but outside the seeded buffer
const { id } = await svc.respond(2000, 'pong');
expect(id).toBeGreaterThan(0);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM, // DM back to the sender of the old rx record
text: 'pong',
channel: 2, // the record's channel
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('rejects invalid messageId and empty content with BadRequestException', async () => {
const svc = new MeshService();
for (const bad of [0, BROADCAST_ADDR + 1, 42.5, 'x' as unknown as number])
await expect(svc.respond(bad, 'hi')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(svc.respond(1, '')).rejects.toThrow(
'content must be a non-empty string',
);
await expect(svc.respond(1, undefined)).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('MeshService opencode notifier', () => {
// our own node number for the self-echo filter specs (distinct from KAMIL_NUM, an external user)
const MY_NODE = 987654321;
let notifyMock: ReturnType<typeof vi.fn>;
beforeEach(() => fetchMock.mockClear());
beforeEach(() => {
notifyMock = vi.fn(
async (_id: number, _text: string, _from?: number) => {},
);
});
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
function myInfoFrame(num: number): Uint8Array {
@@ -582,13 +912,19 @@ describe('MeshService notify webhook', () => {
);
}
it('POSTs {text, from} to MESH_NOTIFY_URL when an rx text lands on the notify channel (default 3)', async () => {
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
function makeSvc(): MeshService {
return new MeshService(undefined, {
notify: notifyMock,
} as unknown as OpencodeService);
}
it('notifies via the opencode service when an rx text lands on the notify channel (default 3)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
@@ -597,36 +933,22 @@ describe('MeshService notify webhook', () => {
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
await waitFor(() => fetchMock.mock.calls.length === 1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://notify.example/hook');
const opts = init as {
method?: string;
headers?: Record<string, string>;
body?: string;
};
expect(opts.method).toBe('POST');
expect(opts.headers?.['content-type']).toBe('application/json');
expect(JSON.parse(String(opts.body))).toEqual({
text: 'ping',
from: KAMIL_NUM,
});
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM); // packet id threaded through
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('does not call fetch for rx texts on other channels', async () => {
it('does not notify for rx texts on other channels', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
@@ -635,109 +957,48 @@ describe('MeshService notify webhook', () => {
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
// the message is captured in history — but no webhook fires off-channel
// the message is captured in history — but no notification fires off-channel
await waitFor(() => svc.messages().length === 1);
expect(fetchMock).not.toHaveBeenCalled();
expect(notifyMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('never calls fetch when MESH_NOTIFY_URL is unset or empty', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_NOTIFY_URL; // unset → disabled
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
try {
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);
expect(fetchMock).not.toHaveBeenCalled();
// empty string → also disabled (a fresh service re-reads the env)
process.env.MESH_NOTIFY_URL = '';
const svc2 = new MeshService();
try {
svc2.onModuleInit();
await waitFor(
() => bridge.sockets.length === 2 && svc2.status().connected,
);
bridge.sockets[1].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
await waitFor(() => svc2.messages().length === 1);
} finally {
delete process.env.MESH_NOTIFY_URL;
svc2.onModuleDestroy();
}
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
svc.onModuleDestroy();
bridge.close();
}
});
it('fires the webhook for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
it('fires the notifier for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => {
process.env.MESH_NOTIFY_CHANNEL = '0';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
const svc = makeSvc();
try {
svc.injectMessage('hi', 42);
const r = svc.injectMessage('hi', 42);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://notify.example/hook');
const opts = init as {
method?: string;
headers?: Record<string, string>;
body?: string;
};
expect(opts.method).toBe('POST');
expect(opts.headers?.['content-type']).toBe('application/json');
expect(JSON.parse(String(opts.body))).toEqual({ text: 'hi', from: 42 });
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42); // generated record id threaded through
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('omits `from` in the webhook body when an injected message has no sender', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
it('passes `from` through as undefined when an injected message has no sender', async () => {
process.env.MESH_NOTIFY_CHANNEL = '0';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
const svc = makeSvc();
try {
svc.injectMessage('hi');
const r = svc.injectMessage('hi');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
const opts = init as { body?: string };
expect(JSON.parse(String(opts.body))).toEqual({ text: 'hi' });
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', undefined);
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('does not call fetch for rx texts from our own node on the notify channel', async () => {
it('does not notify for rx texts from our own node on the 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_URL = 'http://notify.example/hook';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
@@ -748,28 +1009,25 @@ describe('MeshService notify webhook', () => {
bridge.sockets[0].write(myInfoFrame(MY_NODE));
await waitFor(() => svc.status().myNodeNum === MY_NODE);
// an rx text from ourselves on the notify channel must not fire the webhook
// an rx text from ourselves on the notify channel must not fire the notifier
bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3));
// history capture proves the packet was processed (notifyRx runs right after it)
await waitFor(() => svc.messages().length === 1);
expect(fetchMock).not.toHaveBeenCalled();
expect(notifyMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('does not call fetch for an injected message from our own node', async () => {
it('does not notify for an injected message from our own node', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
vi.stubGlobal('fetch', fetchMock);
const svc = new MeshService();
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
@@ -781,106 +1039,165 @@ describe('MeshService notify webhook', () => {
svc.injectMessage('hi', MY_NODE);
expect(fetchMock).not.toHaveBeenCalled();
expect(notifyMock).not.toHaveBeenCalled();
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService forwarded ring', () => {
const MY_NODE = 987654321;
let notifyMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
notifyMock = vi.fn(
async (_id: number, _text: string, _from?: number) => {},
);
});
// myInfo frame as streamed during the initial sync — sets svc.myNodeNum
function myInfoFrame(num: number): Uint8Array {
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'myInfo', value: { myNodeNum: num } },
}),
),
);
}
// MeshService with a mocked OpencodeService wired in (structural cast — only notify() is used)
function makeSvc(): MeshService {
return new MeshService(undefined, {
notify: notifyMock,
} as unknown as OpencodeService);
}
it('records an entry when an rx text passes the gate and is handed off (channel match)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
await waitFor(() => notifyMock.mock.calls.length === 1);
expect(svc.forwarded()).toEqual([
{
id: 1,
from: KAMIL_NUM,
text: 'ping',
channel: 3,
at: expect.any(Number),
},
]);
} finally {
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('retries a failed webhook POST with backoff and stops on success', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error('boom 1'))
.mockRejectedValueOnce(new Error('boom 2'))
.mockResolvedValueOnce(undefined);
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
it('does not record on channel mismatch', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() => fetchMock.mock.calls.length === 3);
// success on the third attempt: exactly 3 POSTs, no dropped log
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
expect(warns.some((w) => w.includes('attempt 2/4'))).toBe(true);
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('retries a webhook POST that answers with an HTTP error status and stops on success', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 502 })
.mockResolvedValueOnce({ ok: false, status: 502 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() => fetchMock.mock.calls.length === 3);
// HTTP 502 twice, then 200: exactly 3 POSTs, no dropped log
expect(fetchMock).toHaveBeenCalledTimes(3);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
expect(warns.some((w) => w.includes('HTTP 502'))).toBe(true);
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('gives up after 4 attempts and logs notify dropped', async () => {
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
process.env.MESH_NOTIFY_CHANNEL = '0';
const fetchMock = vi.fn().mockRejectedValue(new Error('down'));
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const sleep = vi.fn(async (_ms: number) => {});
const svc = new MeshService();
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
try {
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
await waitFor(() =>
warnSpy.mock.calls.some((c) =>
String(c[0]).includes('notify dropped after 4 attempts'),
),
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// exactly 4 attempts, final WARN with the exact message, nothing thrown
expect(fetchMock).toHaveBeenCalledTimes(4);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns).toContain('notify dropped after 4 attempts: down');
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
// the message is captured in history — but off-channel it never reaches the ring
await waitFor(() => svc.messages().length === 1);
expect(notifyMock).not.toHaveBeenCalled();
expect(svc.forwarded()).toEqual([]);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('does not record a self-echo (rx from our own node on the notify channel)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_NOTIFY_CHANNEL; // default 3
const svc = makeSvc();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(myInfoFrame(MY_NODE));
await waitFor(() => svc.status().myNodeNum === MY_NODE);
bridge.sockets[0].write(textPacketFrame(MY_NODE, 42, 'ping', 3));
// history capture proves the packet was processed (notifyRx runs right after it)
await waitFor(() => svc.messages().length === 1);
expect(notifyMock).not.toHaveBeenCalled();
expect(svc.forwarded()).toEqual([]);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
it('does not record when no notifier is wired', () => {
process.env.MESH_NOTIFY_CHANNEL = '0'; // injected records ride channel 0 — the gate would pass
const svc = new MeshService(); // unwired — pinned semantics: nothing forwarded, nothing recorded
try {
svc.injectMessage('hi');
expect(svc.forwarded()).toEqual([]);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('counts injected messages that pass the gate and omits `from` when there is no sender', () => {
process.env.MESH_NOTIFY_CHANNEL = '0';
const svc = makeSvc();
try {
const r = svc.injectMessage('hi'); // no from
expect(svc.forwarded()).toEqual([
{ id: r.id, text: 'hi', channel: 0, at: expect.any(Number) },
]);
expect('from' in svc.forwarded()[0]!).toBe(false);
} finally {
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
it('caps at 100 (oldest evicted), newest first', () => {
process.env.MESH_NOTIFY_CHANNEL = '0';
const svc = makeSvc();
try {
for (let i = 0; i < 101; i++) svc.injectMessage(`m-${i}`);
expect(svc.forwarded()).toHaveLength(100);
expect(svc.forwarded()[0]!.text).toBe('m-100'); // newest first
expect(svc.forwarded()[99]!.text).toBe('m-1'); // m-0 evicted
} finally {
warnSpy.mockRestore();
vi.unstubAllGlobals();
delete process.env.MESH_NOTIFY_URL;
delete process.env.MESH_NOTIFY_CHANNEL;
}
});
@@ -1354,6 +1671,44 @@ describe('MeshService SQLite persistence', () => {
}
});
it('persists the destination (to) of captured rx packets on poller tick', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_POLL_MS = '50';
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const svc = new MeshService(db);
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
// the poller tick must pick it up — no flushHistory() call here
await waitFor(
() =>
(
db.prepare('SELECT COUNT(*) AS n FROM messages').get() as {
n: number;
}
).n === 1,
);
const row = db
.prepare('SELECT * FROM messages WHERE id = ?')
.get(1) as Record<string, unknown>;
expect(row.dir).toBe('rx');
expect(row.from_node).toBe(KAMIL_NUM);
expect(row.to_node).toBe(42); // rx rows carry the destination now (no schema change — to_node existed for tx)
} finally {
delete process.env.MESH_POLL_MS;
svc.onModuleDestroy();
bridge.close();
}
});
it('reflects the ACK status update in the database after the next tick', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
+151 -70
View File
@@ -1,6 +1,8 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
ServiceUnavailableException,
@@ -9,8 +11,10 @@ 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';
import { OpencodeService } from '../opencode/opencode.service.js';
import {
BROADCAST_ADDR,
PORT_NODEINFO_APP,
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TELEMETRY_APP,
@@ -18,6 +22,7 @@ import {
buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio,
decodeNodeInfo,
decodePosition,
decodeRouting,
decodeTelemetry,
@@ -34,7 +39,7 @@ export interface MessageRecord {
dir: 'rx' | 'tx';
/** sender node num — rx only */
from?: number;
/** destination node num (broadcast addr for broadcasts) — tx only */
/** destination node num (broadcast addr for broadcasts) — captured rx packets and all tx records */
to?: number;
text: string;
channel: number;
@@ -46,6 +51,18 @@ export interface MessageRecord {
ackError?: string;
}
/** One entry in the forwarded ring: an rx text that passed the notify gate and was handed off to opencode. */
export interface ForwardedRecord {
/** packet id (real rx) or generated record id (injected) */
id: number;
/** sender node num — omitted when there is no sender */
from?: number;
text: string;
channel: number;
/** epoch ms */
at: number;
}
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
export interface TelemetryRecord {
/** sender node num */
@@ -81,6 +98,22 @@ export function initMessagesTable(db: DatabaseSync): void {
db.exec(MESSAGES_DDL);
}
/** Map a messages-table row to a MessageRecord (NULLs → omitted optionals). */
function recordFromRow(row: Record<string, unknown>): MessageRecord {
const rec: MessageRecord = {
id: Number(row.id),
dir: row.dir === 'tx' ? 'tx' : 'rx',
text: String(row.text),
channel: Number(row.channel),
at: Number(row.at),
};
if (row.to_node != null) rec.to = Number(row.to_node);
if (row.from_node != null) rec.from = Number(row.from_node);
if (row.status != null) rec.status = row.status as MessageRecord['status'];
if (row.ack_error != null) rec.ackError = String(row.ack_error);
return rec;
}
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
@@ -130,8 +163,6 @@ function jsonSafe<T>(value: T): T {
// key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED';
// ponytail: hardcoded notify-webhook backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
const NOTIFY_RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
function redactSection(
name: string,
@@ -159,8 +190,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
process.env.MESH_SEND_CHANNEL ?? '3',
10,
);
// outbound notification webhook: fire-and-forget POST when an rx text lands on the notify channel; empty URL = disabled
private readonly notifyUrl = process.env.MESH_NOTIFY_URL ?? '';
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on the notify channel;
// disabled state (missing credentials) lives in OpencodeService itself
private readonly notifyChannel = parseInt(
process.env.MESH_NOTIFY_CHANNEL ?? '3',
10,
@@ -170,10 +201,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
/** SQLite database backing the message history; null = persistence disabled (tests construct without one). */
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;
private pollTimer: NodeJS.Timeout | null = null;
constructor(db?: DatabaseSync) {
constructor(db?: DatabaseSync, opencode?: OpencodeService) {
this.db = db ?? null;
this.opencode = opencode ?? null;
}
private socket: net.Socket | null = null;
@@ -193,6 +227,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
private moduleConfigSections = new Map<string, Record<string, unknown>>();
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
private history: MessageRecord[] = [];
// rx texts handed off to the opencode notifier (newest first, capped at HISTORY_LIMIT); in-memory only
private forwardedHistory: ForwardedRecord[] = [];
private pendingTx = new Map<number, MessageRecord>();
// telemetry history (newest kept, capped at HISTORY_LIMIT)
private telemetryHistory: TelemetryRecord[] = [];
@@ -354,6 +390,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return [...this.history].reverse();
}
/**
* The last HISTORY_LIMIT rx texts that passed the notify gate and were handed off to opencode, newest first:
* channel matched MESH_NOTIFY_CHANNEL, not a self-echo, and a notifier is wired. In-memory only — reset on restart.
*/
forwarded(): ForwardedRecord[] {
return [...this.forwardedHistory];
}
/**
* Re-upsert the entire in-memory history buffer into SQLite (INSERT OR REPLACE per row) and enforce
* retention: only the newest DB_RETENTION rows survive. Called by the poller every MESH_POLL_MS;
@@ -389,21 +433,55 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages ORDER BY at DESC LIMIT ?',
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
// rows come back newest first — the buffer is oldest-first
for (const row of [...rows].reverse()) {
const rec: MessageRecord = {
id: Number(row.id),
dir: row.dir === 'tx' ? 'tx' : 'rx',
text: String(row.text),
channel: Number(row.channel),
at: Number(row.at),
};
if (row.to_node != null) rec.to = Number(row.to_node);
if (row.from_node != null) rec.from = Number(row.from_node);
if (row.status != null)
rec.status = row.status as MessageRecord['status'];
if (row.ack_error != null) rec.ackError = String(row.ack_error);
this.history.push(rec);
}
for (const row of [...rows].reverse())
this.history.push(recordFromRow(row));
}
/** Look up a history record by packet id: in-memory buffer first, then SQLite (newest 1000). */
private findRecord(id: number): MessageRecord | undefined {
const hit = this.history.find((r) => r.id === id);
if (hit) return hit;
const db = this.db;
if (!db) return undefined;
const row = db
.prepare(
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages WHERE id = ?',
)
.get(id) as Record<string, unknown> | undefined;
return row ? recordFromRow(row) : undefined;
}
/**
* Reply to an earlier message by packet id — the caller never thinks about where it goes:
* rx records are answered as a DM back to the original sender, tx records go to their destination;
* broadcast originals (and 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. Returns { id } with the new packet id. Throws BadRequest on invalid
* arguments, NotFound when no message has the given id (in memory or in SQLite), 503 when the bridge is down.
*/
async respond(messageId: number, content: string): Promise<{ id: number }> {
if (
!Number.isInteger(messageId) ||
messageId < 1 ||
messageId > BROADCAST_ADDR
)
throw new BadRequestException(
'messageId must be an integer in 1..0xffffffff',
);
if (typeof content !== 'string' || content.length === 0)
throw new BadRequestException('content must be a non-empty string');
const rec = this.findRecord(messageId);
if (!rec) throw new NotFoundException(`no message with id ${messageId}`);
// rx → DM back to the sender unless the original was a broadcast (then reply as a broadcast — that's what the stored destination is for);
// tx → its own destination; injected records without a sender → broadcast
const target =
rec.dir === 'rx'
? rec.to === BROADCAST_ADDR
? BROADCAST_ADDR
: rec.from
: rec.to;
const to = target ?? BROADCAST_ADDR;
return { id: await this.sendText(to, content, messageId, rec.channel) };
}
/**
@@ -428,54 +506,33 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
at: Date.now(),
};
this.pushHistory(rec);
// injected records ride channel 0 — the webhook fires only when MESH_NOTIFY_CHANNEL is set to match
this.notifyRx(0, text, from);
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
this.notifyRx(0, text, from, rec.id);
return { id: rec.id };
}
/** Fire-and-forget notify-webhook POST for an rx text on `channel` (no-op unless URL is set and channels match). */
private notifyRx(channel: number, text: string, from?: number): void {
if (this.notifyUrl && channel === this.notifyChannel) {
// 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}`);
return;
}
const body: Record<string, unknown> = { text };
if (from !== undefined) body.from = from;
void this.postNotify(body);
/** 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. */
private notifyRx(
channel: number,
text: string,
from?: number,
id?: number,
): void {
if (!this.opencode || channel !== this.notifyChannel) 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}`);
return;
}
}
/** POST the webhook with retries: up to NOTIFY_RETRY_DELAYS_MS.length + 1 attempts total. */
private async postNotify(body: Record<string, unknown>): Promise<void> {
const maxAttempts = NOTIFY_RETRY_DELAYS_MS.length + 1;
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(this.notifyUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res && !res.ok) throw new Error('HTTP ' + res.status); // HTTP errors retry like network failures
return; // success on any attempt — same as today's single POST
} catch (err: unknown) {
const msg = (err as Error).message;
this.logger.warn(
`notify webhook failed (attempt ${attempt}/${maxAttempts}): ${msg}`,
);
if (attempt === maxAttempts) {
this.logger.warn(`notify dropped after ${attempt} attempts: ${msg}`);
return;
}
await this.sleep(NOTIFY_RETRY_DELAYS_MS[attempt - 1]);
}
}
}
/** Injectable so specs can skip the backoff delays. */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
// record the forward at hand-off — the ring holds exactly what a wired notifier receives (newest first).
// Pinned semantics: an unwired notifier records nothing (early return above); a notifier disabled internally
// (empty credentials) still counts — MeshService cannot see its enabled flag.
const entry: ForwardedRecord = { id: id!, text, channel, at: Date.now() };
if (from !== undefined) entry.from = from;
this.forwardedHistory.unshift(entry);
if (this.forwardedHistory.length > HISTORY_LIMIT)
this.forwardedHistory.pop(); // oldest evicted
void this.opencode.notify(id!, text, from); // both call sites always pass the packet id
}
/**
@@ -488,17 +545,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return [...this.telemetryHistory].reverse();
}
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
async sendText(to: number, text: string): Promise<number> {
/** Send `text` to node `to`; returns the packet id. Rides the pinned test channel unless an explicit `channel` index is given (e.g. battery alerts on "testowy"). Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */
async sendText(
to: number,
text: string,
replyId?: number,
channel?: number,
): Promise<number> {
if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected');
const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
const ch = channel ?? this.sendChannel;
const { bytes, id } = await buildTextToRadio(to, text, ch, replyId);
const rec: MessageRecord = {
id,
dir: 'tx',
to,
text,
channel: this.sendChannel,
channel: ch,
at: Date.now(),
status: 'pending',
};
@@ -650,11 +713,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
id: pkt.id,
dir: 'rx',
from: pkt.from,
to: pkt.to, // destination (broadcast addr for broadcasts) — lets a later reply tell DM vs broadcast apart
text,
channel: pkt.channel ?? 0,
at: Date.now(),
});
this.notifyRx(pkt.channel ?? 0, text, pkt.from);
this.notifyRx(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;
@@ -671,6 +735,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
position: pos,
});
}
} else if (decoded && decoded.portnum === PORT_NODEINFO_APP) {
// live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries
// battery_level so /api/nodes and the battery alerts see fresh values between syncs
let ni: Proto.Mesh.NodeInfo | undefined;
try {
ni = await decodeNodeInfo(decoded.payload);
} catch {
ni = undefined; // malformed payload — skip, keep whatever we had
}
if (ni?.deviceMetrics !== undefined) {
const num = pkt.from;
this.nodeMap.set(num, {
...this.nodeMap.get(num),
num,
deviceMetrics: ni.deviceMetrics,
});
}
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
// Data.requestId = the original packet id we sent
+324
View File
@@ -0,0 +1,324 @@
import { Logger } from '@nestjs/common';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OpencodeService } from './opencode.service.js';
const API = 'http://172.24.0.1:9898';
const WS_PATH = '/home/krzyw/projects/sandbox/mesh-bot';
// the workspace UUID is regenerated on host restarts — only path/name/proxyPath matter
const WORKSPACE = {
id: 'ws-uuid',
path: WS_PATH,
name: 'mesh-bot',
status: 'ready',
proxyPath: '/proxy/abc',
};
function json(body: unknown): Response {
return { ok: true, status: 200, json: async () => body } as Response;
}
function timeoutError(): Error {
return new DOMException('signal timed out', 'TimeoutError');
}
/** Fresh service with a clean env (creds unset by default — pass them explicitly). */
function makeService(env: Record<string, string> = {}): OpencodeService {
process.env.OPENCODE_API = API;
process.env.OPENCODE_WORKSPACE_PATH = WS_PATH;
for (const k of ['OPENCODE_SESSION', 'OPENCODE_USER', 'OPENCODE_PASS'])
delete process.env[k];
Object.assign(process.env, env);
return new OpencodeService();
}
function stubSleep(svc: OpencodeService): ReturnType<typeof vi.fn> {
const sleep = vi.fn(async (_ms: number) => {});
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
return sleep;
}
describe('OpencodeService.notify', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
for (const k of [
'OPENCODE_API',
'OPENCODE_WORKSPACE_PATH',
'OPENCODE_SESSION',
'OPENCODE_USER',
'OPENCODE_PASS',
])
delete process.env[k];
});
it('posts the exact wrapper text with `from` (em-dash) to the resolved session', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi', 42);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
const opts = init as {
method?: string;
headers?: Record<string, string>;
body?: string;
};
expect(opts.method).toBe('POST');
expect(opts.headers?.['content-type']).toBe('application/json');
expect(opts.headers?.authorization).toBe(
'Basic ' + Buffer.from('u:p').toString('base64'),
);
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
expect(String(opts.body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
},
],
}),
);
});
it('omits the `from node` part when there is no sender', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
const [, init] = fetchMock.mock.calls[1];
expect(String((init as { body?: string }).body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7): "hi" — please react to it.',
},
],
}),
);
});
it('matches the workspace by path, preferring ready entries when several match', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock.mockResolvedValueOnce(
json([
{
...WORKSPACE,
id: 'other',
status: 'starting',
proxyPath: '/proxy/not-ready',
}, // same path but not ready — listed first
WORKSPACE,
{
id: 'x',
name: 'mesh-bot',
status: 'ready',
proxyPath: '/proxy/other',
}, // different path — must lose to the path match
]),
);
fetchMock.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
);
});
it('falls back to the workspace name when no path matches', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock.mockResolvedValueOnce(
json([
{
id: 'a',
path: '/elsewhere',
name: 'other',
status: 'ready',
proxyPath: '/proxy/a',
},
{
id: 'b',
path: '/renamed',
name: 'mesh-bot',
status: 'ready',
proxyPath: '/proxy/b',
},
]),
);
fetchMock.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/b/session/ses_pinned/message`,
);
});
it('discovers the most recently updated session when none is pinned', async () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(
json([
{ id: 'ses_old', time: { updated: 100 } },
{ id: 'ses_new', time: { updated: 200 } },
]),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_new/message`,
);
});
it('uses the first session when none carry time.updated', async () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_a/message`,
);
});
it('skips session discovery entirely when a session is pinned', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
// workspaces GET + message POST only — no /session discovery GET
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
);
});
it('treats a final-POST read timeout as delivered success and does not retry', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
const logSpy = vi.spyOn(Logger.prototype, 'log');
await expect(svc.notify('hi')).resolves.toBeUndefined();
// exactly 2 calls: no retry after a timeout-as-delivered
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(logSpy.mock.calls.map((c) => String(c[0]))).toContain(
'opencode notify accepted (response still pending after 15 s)',
);
});
it('retries a failed workspace GET and succeeds on the next attempt', async () => {
const svc = makeService({
OPENCODE_USER: 'u',
OPENCODE_PASS: 'p',
OPENCODE_SESSION: 'ses_pinned',
});
const sleep = stubSleep(svc);
fetchMock
.mockRejectedValueOnce(new Error('boom')) // attempt 1: workspaces GET fails
.mockResolvedValueOnce(json([WORKSPACE])) // attempt 2: workspaces ok
.mockResolvedValueOnce(json({})); // message POST ok
await expect(svc.notify('hi')).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
});
it('gives up after 4 attempts and logs opencode notify dropped', async () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
const sleep = stubSleep(svc);
fetchMock.mockRejectedValue(new Error('down'));
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
await expect(svc.notify('hi')).resolves.toBeUndefined(); // never throws — fire-and-forget safe
// one workspaces GET per attempt: exactly 4 attempts total
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
// the GET failure is wrapped with the URL for a clear reason — match prefix + cause
expect(
warns.some(
(w) =>
w.startsWith('opencode notify dropped after 4 attempts:') &&
w.includes(': down'),
),
).toBe(true);
});
it('is a no-op without credentials (empty or unset)', async () => {
for (const env of [{}, { OPENCODE_USER: '', OPENCODE_PASS: 'p' }] as Record<
string,
string
>[]) {
const svc = makeService(env);
await expect(svc.notify(7, 'hi', 42)).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
}
});
it('logs once at construction when disabled', () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
makeService({}); // no creds
expect(warnSpy.mock.calls.map((c) => String(c[0]))).toContain(
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { Injectable, Logger } from '@nestjs/common';
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes
const TIMEOUT_MS = 15_000;
/** Entry of GET {api}/api/workspaces (only the fields we use). */
interface WorkspaceEntry {
id?: string;
path?: string;
name?: string;
status?: string;
proxyPath?: string;
}
/** Entry of GET {base}/session (only the fields we use). */
interface SessionEntry {
id: string;
time?: { updated?: number };
}
@Injectable()
export class OpencodeService {
private readonly logger = new Logger(OpencodeService.name);
// read at construction, like MeshService's env config
private readonly api = process.env.OPENCODE_API ?? 'http://172.0.0.1:9898';
private readonly workspacePath =
process.env.OPENCODE_WORKSPACE_PATH ??
'/home/krzyw/projects/sandbox/mesh-bot';
// when set, skips session discovery
private readonly sessionOverride = process.env.OPENCODE_SESSION ?? '';
private readonly user = process.env.OPENCODE_USER ?? '';
private readonly pass = process.env.OPENCODE_PASS ?? '';
/** Notifier disabled without credentials — the app must keep running (and notify nothing) when they are missing. */
private readonly enabled = this.user !== '' && this.pass !== '';
constructor() {
if (!this.enabled)
this.logger.warn(
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
);
}
/**
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered —
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
* after N attempts`. No-op when disabled (no credentials).
*/
async notify(id: number, text: string, from?: number): Promise<void> {
if (!this.enabled) return;
const msg =
`New mesh message (id ${id})` +
(from != null ? ` from node ${from}` : '') +
`: "${text}" — please react to it.`;
const maxAttempts = RETRY_DELAYS_MS.length + 1;
for (let attempt = 1; ; attempt++) {
try {
const { base, sid } = await this.resolveTarget();
await this.postMessage(base, sid, msg);
return; // success on any attempt — a POST timeout already resolved as delivered inside postMessage
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (attempt === maxAttempts) {
this.logger.warn(
`opencode notify dropped after ${attempt} attempts: ${message}`,
);
return;
}
this.logger.warn(
`opencode notify failed (attempt ${attempt}/${maxAttempts}): ${message}`,
);
await this.sleep(RETRY_DELAYS_MS[attempt - 1]);
}
}
}
/** Injectable so specs can skip the backoff delays. */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private authHeaders(): Record<string, string> {
return {
authorization:
'Basic ' + Buffer.from(`${this.user}:${this.pass}`).toString('base64'),
};
}
/** Resolve (base_url, session_id) fresh per request; throws with a clear reason on API/workspace/session failure. */
private async resolveTarget(): Promise<{ base: string; sid: string }> {
const workspaces = await this.getJson<WorkspaceEntry[]>(
`${this.api}/api/workspaces`,
);
// match by path first, fall back to name — the workspace UUID is regenerated on host restarts
const matches =
workspaces.filter((w) => w.path === this.workspacePath).length > 0
? workspaces.filter((w) => w.path === this.workspacePath)
: workspaces.filter((w) => w.name === 'mesh-bot');
// prefer ready entries when several match
const pool =
matches.filter((w) => w.status === 'ready').length > 0
? matches.filter((w) => w.status === 'ready')
: matches;
if (pool.length === 0)
throw new Error(
`workspace not found: path=${this.workspacePath} name=mesh-bot`,
);
const ws = pool[0];
const base = this.api + (ws.proxyPath ?? '');
let sid = this.sessionOverride;
if (!sid) {
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`);
if (sessions.length === 0)
throw new Error(
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
);
// most recently updated session; first entry when none carry time.updated
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
sid = sessions.some((s) => updated(s))
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
: sessions[0].id;
}
return { base, sid };
}
private async getJson<T>(url: string): Promise<T> {
let res: Response;
try {
res = await fetch(url, {
headers: this.authHeaders(),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
throw new Error(
`opencode API unreachable at ${url}: ${(err as Error).message}`,
);
}
if (!res.ok) throw new Error(`opencode API HTTP ${res.status} at ${url}`);
return (await res.json()) as T;
}
private async postMessage(
base: string,
sid: string,
msg: string,
): Promise<void> {
try {
const res = await fetch(`${base}/session/${sid}/message`, {
method: 'POST',
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
} catch (err) {
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
// the response until the agent turn finishes; never retried
if ((err as Error).name === 'TimeoutError') {
this.logger.log(
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
);
return;
}
throw err;
}
}
}