Compare commits

...
56 Commits
Author SHA1 Message Date
kamilkrzywda a7df87a5a1 Merge pull request 'Remove insecure NODE_TLS_REJECT_UNAUTHORIZED=0 TLS workaround (follow-up to #56)' (#57) from feat/meshmonitor-connection into main
CI / build + unit (push) Successful in 48s
Merge pull request 'Remove insecure NODE_TLS_REJECT_UNAUTHORIZED=0 TLS workaround' (#57) from feat/meshmonitor-connection into main
2026-10-13 03:13:21 +02:00
kamilkrzywda 2c4b4c5968 Merge pull request 'Drive MeshService through the MeshMonitor HTTP gateway (Phase 2)' (#56) from feat/meshmonitor-connection into main
CI / build + unit (push) Successful in 39s
Merge pull request 'Drive MeshService through the MeshMonitor HTTP gateway' (#56) from feat/meshmonitor-connection into main
2026-10-13 03:10:15 +02:00
kamilkrzywdaandOpenCode 25f99f04ad Remove insecure NODE_TLS_REJECT_UNAUTHORIZED=0 TLS workaround
CI / build + unit (pull_request) Successful in 46s
The MeshMonitor gateway uses a valid Let's Encrypt certificate, so
standard TLS certificate verification already works — no workaround
needed. The flag was a security downgrade: it disabled cert verification
for ALL outbound connections in the container (including the LLM and
opencode services), not just the gateway.

- docker-compose.yml: drop NODE_TLS_REJECT_UNAUTHORIZED env line + comment
- src/main.ts: drop process.env.NODE_TLS_REJECT_UNAUTHORIZED + comments
- CHANGELOG.md: correct the v0.22.0 Docker bullet wording

Part of the already-open MeshMonitor connection work (PR #56): no version
bump, no new changelog section.

Co-Authored-By: OpenCode <noreply@opencode.ai>
2026-10-13 01:07:36 +00:00
kamilkrzywda f6b884dfdf feat(mesh): drive MeshService through the MeshMonitor HTTP gateway (Phase 2)
CI / build + unit (pull_request) Successful in 1m17s
Rewire MeshService off the raw TCP socket + codec onto the Phase-1
MeshMonitorClient contract (HttpMeshMonitorClient in production,
FakeMeshMonitorClient in tests): status/messages/nodes/channels/telemetry
are polled (15s/1s/30s/5min/60s) instead of streamed, outbound broadcasts
and DMs go through client.send(), respond/inject/notifier/command/battery/
weather behavior and all /api/* + MCP endpoint shapes are preserved.
The fake TCP bridge is deleted and every consumer spec (mesh service, MCP
controller, command auto-responder, battery alerts) now drives the real
service through the widened fake client. Docker compose drops
MESH_HOST/MESH_PORT and adds the MESHMONITOR_* env (token from .env,
self-signed TLS via NODE_TLS_REJECT_UNAUTHORIZED=0).

Live smoke against https://meshmonitor.kkhost.pl: getStatus ->
{connected:true, myNodeNum:3994646728, longName:'SP8MK Bot',
nodeResponsive:true}; getNodes -> 104 nodes.

Build green, suite 17 files / 245 tests (baseline 234).
2026-10-13 00:42:02 +00:00
kamilkrzywda f80340ae6e feat(meshmonitor): add stateless MeshMonitor HTTP-gateway client + fake + module
Phase 1 of the MeshMonitor HTTP-gateway migration: a stateless REST client
that a later phase wires into MeshService to replace the raw
Meshtastic protobuf-over-TCP bridge.

- mesh-monitor-client.ts: MeshMonitorClient interface, HttpMeshMonitorClient
  (native fetch, Bearer auth, injectable base/source/poll params), mappers for
  the gateway's actual JSON shapes (source-scoped paths, flat node position,
  second-based node lastHeard vs ms message/telemetry timestamps, long-format
  telemetry, packet id = 3rd segment of the id string), MeshMonitorError,
  nodeNumToId/nodeIdToNum, shared assertSendTarget XOR, and
  createMeshMonitorClientFromEnv() (null when MESHMONITOR_API_TOKEN unset).
- fake-mesh-monitor-client.ts: in-memory double mirroring the client contract
  (set*/pushInbound config, sends[] recorder, outbound mirrored into
  getMessages, newest-first, limit/channel/toNodeId/fromNodeId filters).
- meshmonitor.module.ts: thin Nest provider exposing the env-built client
  (or null) under MESH_MONITOR_CLIENT for Phase 2's MeshModule.
- specs: HttpMeshMonitorClient against an in-process node:http gateway
  (raw->typed mapping per endpoint, Authorization: Bearer header, send body
  shape with channel/toNodeId XOR + replyId passthrough, 401/413/429/503
  -> MeshMonitorError); FakeMeshMonitorClient behavior.

No MeshService changes, no version bump, no changelog, no PR.
2026-10-12 20:29:50 +00:00
kamilkrzywda b54f909fbc Merge pull request 'Ignore 0% battery readings in low-battery alerting' (#55) from fix/battery-zero-ignore into main
CI / build + unit (push) Successful in 1m10s
2026-10-06 12:53:41 +02:00
kamilkrzywda 7ec97a06d2 Ignore 0% battery readings in low-battery alerting
CI / build + unit (pull_request) Successful in 1m18s
A 0% batteryLevel is an invalid/no-data value (no battery wired or
misconfigured node), not a genuinely critical battery, so it must not
produce a "⚠️ battery low" alert. Add a non-positive guard to
shouldAlert after the undefined check: 0 (and any non-positive) now
skips, while 1-20 still alert and >20 still doesn't. Threshold/cooldown/
batching unchanged.

Tests: 3 new specs in the check-threshold block (0% excluded from the
batch, lone 0% sends nothing, 1% still alerts). 180 -> 183.
2026-10-06 08:02:37 +00:00
kamilkrzywda 84915fe314 Merge pull request 'fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)' (#54) from fix/opencode-v2-notifier into main
CI / build + unit (push) Successful in 22s
2026-10-05 15:25:38 +02:00
kamilkrzywda 5506fb9330 fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)
CI / build + unit (pull_request) Successful in 46s
CodeNomad's per-workspace instance proxy was upgraded and now only
serves opencode v2 /api/* routes; the v1 /session/... routes 403, which
broke all notifications.

- Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope)
  instead of GET {base}/session; sessions filtered by location.directory
  and the max time.updated entry is picked.
- Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text}
  body instead of POST {base}/session/{id}/message with {parts:[...]}.
- v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered
  branch is kept only as a harmless safety net.
- Updated the spec to the v2 routes/body/envelope (incl. a
  directory-filter assertion) and kept AGENTS.md in sync.
2026-10-05 12:48:47 +00:00
kamilkrzywda beebcd5851 Merge pull request 'Container TZ: Europe/Warsaw so cron hours are local (v0.21.10)' (#53) from fix/tz-warsaw into main
CI / build + unit (push) Successful in 1m16s
2026-09-30 07:49:50 +02:00
kamilkrzywda 5a1493fb4a Run container in Europe/Warsaw so cron hours and logs use local Polish time (v0.21.10)
CI / build + unit (pull_request) Successful in 50s
2026-09-30 05:47:10 +00:00
kamilkrzywda 8fb16217f7 Merge pull request 'Add #loser command — worst packet delivery record (v0.21.9)' (#52) from fix/loser-command into main
CI / build + unit (push) Successful in 1m36s
2026-09-29 23:01:29 +02:00
kamilkrzywda 3b1f5de101 Add #loser command — worst packet delivery record over directed sends (v0.21.9)
CI / build + unit (pull_request) Successful in 48s
2026-09-29 20:58:43 +00:00
kamilkrzywda 46d32b3553 Merge pull request 'Fix unhandled ECONNRESET in fake-bridge teardown — npm test exits 0 again (v0.21.8)' (#51) from fix/spec-teardown-econnreset into main
CI / build + unit (push) Successful in 1m11s
2026-09-27 09:18:09 +02:00
kamilkrzywda e20e11e2d5 Fix npm test exit 1 — uncaught ECONNRESET from fake-bridge sockets without error handlers (v0.21.8)
CI / build + unit (pull_request) Successful in 46s
2026-09-27 07:13:56 +00:00
kamilkrzywda 0b20df242c Merge pull request 'Fix double ScheduleModule.forRoot() — all cron jobs fired twice (v0.21.7)' (#50) from fix/schedule-double-registration into main
CI / build + unit (push) Failing after 34s
2026-09-27 08:52:35 +02:00
kamilkrzywda a16b6c19d4 Fix double ScheduleModule.forRoot() registration doubling every cron/interval job (v0.21.7)
CI / build + unit (pull_request) Failing after 22s
2026-09-27 06:51:50 +00:00
kamilkrzywda 9e558fd95e Merge pull request 'Add #help command with short list of all commands (v0.21.6)' (#49) from fix/help-command into main
CI / build + unit (push) Failing after 34s
2026-09-26 12:54:06 +02:00
kamilkrzywda 70be8bdaa7 Add #help command — short single-message list of all commands (v0.21.6)
CI / build + unit (pull_request) Failing after 21s
Owner request: 'now add #help with short list of all command'. Registered in
the CommandService constructor next to #ping; returns a SINGLE multi-line
string (one packet, ~203 chars) — not an array, which would drip-feed one
spaced packet per line through the v0.21.5 MESH_MULTI_SEND_DELAY_MS loop.

Also hardened the v0.21.5 multi-send-spacing spec: its wall-clock assertion
('first send < 8 ms after t0') flaked under full-suite load — replaced with
fake timers, which assert deterministically that line 1 is out before any time
elapses and lines 2-3 land exactly one delay apart.
2026-09-26 10:52:18 +00:00
kamilkrzywda 847c3d47d0 Merge pull request 'Space multi-line command replies 5 s apart (v0.21.5)' (#48) from fix/multi-send-spacing into main
CI / build + unit (push) Failing after 46s
2026-09-26 12:31:22 +02:00
kamilkrzywda 1b768b14a7 Space multi-line command replies MESH_MULTI_SEND_DELAY_MS apart (v0.21.5)
CI / build + unit (pull_request) Failing after 21s
Production incident: '#rss get lublin' returned an array of lines and
CommandService sent each one back-to-back within the same second — on the real
mesh 3 of 4 items were dropped in RF (the owner saw only the last one).

CommandService.onRx now waits MESH_MULTI_SEND_DELAY_MS (new env, default 5000 =
5 s, read at construction like the other timing knobs) between consecutive
lines of an array reply — no delay before the first line. The delay sits in the
shared per-line loop, so both the respond() path and the legacy sendText
fallback are covered by one await.
2026-09-26 10:29:21 +00:00
kamilkrzywda 3f6c02d71e Merge pull request 'LLM timeout default 60 s → 180 s (v0.21.4)' (#47) from fix/llm-timeout into main
CI / build + unit (push) Failing after 38s
2026-09-26 11:55:54 +02:00
kamilkrzywda 98b9ec5185 LLM_TIMEOUT_MS default 60 s -> 3 min (v0.21.4)
CI / build + unit (pull_request) Failing after 21s
Production incident: '#rss get lublin' at 9:41 AM failed with 'The operation
was aborted due to timeout' — the LlmService call hit its 60 s cap while the
local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens)
was slow/busy. The same endpoint answers a trivial prompt in ~1 s when idle,
so this is latency variance on the server side, not a dead endpoint.

Bump the LLM_TIMEOUT_MS fallback from 60000 to 180000 (3 min headroom for
slow reasoning periods); still env-overridable, only the default changed.
2026-09-26 09:52:18 +00:00
kamilkrzywda 0722c82359 Merge pull request 'Reconnect tuning: 1 min idle timeout + 5 min periodic autoreconnect (v0.21.3)' (#46) from fix/reconnect-tuning into main
CI / build + unit (push) Failing after 42s
2026-09-26 11:37:41 +02:00
kamilkrzywda 1ae8d7a926 Idle timeout default 60 s; lazy periodic autoreconnect every 5 min (v0.21.3)
CI / build + unit (pull_request) Failing after 22s
The v0.21.2 frame-based watchdog works in production (it fired today: 'no
decoded bridge frames for 628474 ms — forcing reconnect', and the reconnect
delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps
killing inbound RX within ~20 min of a fresh sync, repeatedly. Two tuning
changes requested by the owner:

- MESH_IDLE_TIMEOUT_MS default lowered from 600000 (10 min) to 60000 (1 min).
- New lazy periodic autoreconnect: MESH_RECONNECT_INTERVAL_MS (new env,
  default 300000 = 5 min, read at construction like the others). An interval
  started in onModuleInit unconditionally logs and destroys the socket so the
  existing close -> scheduleReconnect loop re-establishes with a fresh sync on
  schedule; cleared in onModuleDestroy. When not connected at tick time,
  destroy is a no-op and the retry loop owns recovery — no special-casing.

Tests: 171 -> 172 — periodic reconnect observed end-to-end with an overridden
short interval (fake bridge disconnect + re-establish); existing watchdog tests
pass unchanged, comments updated to the new 60 s default.
2026-09-26 09:34:30 +00:00
kamilkrzywda 77459adc13 Merge pull request 'Watchdog: track decoded frames, not raw bytes (v0.21.2)' (#45) from fix/watchdog-frame-tracking into main
CI / build + unit (push) Failing after 21s
2026-09-26 10:53:28 +02:00
kamilkrzywda 33a88f3d83 Watchdog tracks decoded frames, not raw bytes; buffer-overflow backstop (v0.21.2)
CI / build + unit (pull_request) Failing after 39s
Second production zombie slipped past v0.21.1's watchdog: text+telemetry RX
dead for >1 h while TX worked and the watchdog never fired. Root cause proven
at runtime via /proc/net/snmp — InSegs delta = 2 segments per 30 s (OutSegs 0):
the bridge peer keeps dribbling small NON-frame bytes (~every 15 s, keepalive
noise or a desynced stream) and v0.21.1 updated lastPacketAt on EVERY socket
chunk, so 'no traffic for MESH_IDLE_TIMEOUT_MS' was never true even though zero
valid Meshtastic frames decoded.

- The 'data' handler no longer updates lastPacketAt per chunk; it is updated
  only when extractFrames yields >=1 valid packet (the reset in the 'connect'
  handler is unchanged), so a stream that decodes nothing trips the watchdog
  after MESH_IDLE_TIMEOUT_MS even while raw bytes keep arriving.
- Buffer-overflow backstop: if the undecoded frame buffer exceeds
  MESH_MAX_FRAME_BUFFER (new env, default 256000) — e.g. one data event of a
  desynced stream that never forms frames — log ERROR and destroy the socket;
  the existing close -> scheduleReconnect loop self-heals it. The undecoded
  buffer is also reset on every (re)connect so leftover garbage from a dead
  session can't re-trigger the backstop or corrupt the first frames of a
  healthy new stream.

Tests: 169 -> 171 — raw non-frame bytes do NOT keep the watchdog alive
(garbage-only stream forces disconnect + reconnect, today's incident shape),
and an undecoded chunk over MESH_MAX_FRAME_BUFFER triggers destroy + a fresh
session that decodes normally (cap env-overridden to 100 since Node delivers
socket data in ~64 KB events, far below the 256_000 default).
2026-09-26 08:50:27 +00:00
kamilkrzywda 306756c63b Merge pull request 'Bridge idle watchdog + forced reconnect (v0.21.1)' (#44) from fix/bridge-idle-reconnect into main
CI / build + unit (push) Successful in 45s
2026-09-26 08:53:06 +02:00
kamilkrzywda af1c77fd54 Force reconnect when the bridge connection goes silent (v0.21.1)
CI / build + unit (pull_request) Successful in 1m8s
Production incident: the bridge TCP connection went zombie — outbound sends
kept working for ~10 h while inbound packets stopped entirely, and
/api/status stayed connected:true because nothing detects inbound silence on
an established connection.

- Track lastPacketAt: updated on every chunk received from the bridge in the
  lowest-level socket 'data' path (any frame kind, decodable or not) and reset
  when a connection successfully starts, so a fresh connection can't
  self-trigger.
- Idle watchdog: a 30 s interval checks connected AND now - lastPacketAt >
  MESH_IDLE_TIMEOUT_MS (new env, default 600000 = 10 min) — logs WARN with the
  idle duration and destroys the socket so the EXISTING connect/retry loop
  re-establishes it with a fresh sync. No changes to connect-error retry or
  backoff; this only adds detection for established-but-silent connections.
- TCP keepalive: socket.setKeepAlive(true, 30_000) on the bridge socket —
  cheap insurance against dead peers at the TCP level.

Tests (fake-bridge based): silence past an overridden timeout forces a
disconnect + reconnect; inbound traffic keeps resetting the idle clock so no
false trigger; the default timeout does not fire in a test window; and
setKeepAlive(true, 30_000) is asserted on the socket. checkIdle() is public so
tests drive a tick without waiting for the real interval (like flushHistory()).
2026-09-26 06:50:01 +00:00
kamilkrzywda 73ce49c741 Merge pull request '#rss get: 3-5 separate messages with short headers (v0.21.0)' (#43) from feat/rss-multi-message into main
CI / build + unit (push) Successful in 41s
2026-09-25 22:12:05 +02:00
kamilkrzywda cdd52a3ddb Send #rss get summaries as 3-5 separate mesh messages (v0.21.0)
CI / build + unit (pull_request) Successful in 1m20s
The single-message summary was too small (owner request). The summarizer
prompt now asks for exactly 3-5 lines, one item per line, in the format
'<short header>: <one or two short sentences>' with a <=20-char header,
no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom).

The reply is parsed into items: drop empty lines and lines without a ':'
header separator, cap at 5; fewer than 3 valid lines sends what we got
(no failure/retry), zero valid lines sends nothing. Each item goes out as
its own mesh message via respond(id, line) - all threaded to the trigger
packet id on its channel. Per-item backstop fitOneLine() hard-cuts any
line >185 chars at a word boundary near 180 so every item fits one packet;
the old whole-reply fitOnePacket() cut is gone.

CommandService sends array replies line by line through the same routing
as single replies (respond per line, or legacy sendText fallback per line
when there is no packet id). LLM failure path unchanged: single
'summary failed' reply.
2026-09-25 20:09:16 +00:00
kamilkrzywda 172e7bed9c Merge pull request 'Cap #rss get summaries to one mesh packet (v0.20.1)' (#42) from fix/rss-summary-length into main
CI / build + unit (push) Successful in 1m10s
2026-09-25 21:25:02 +02:00
kamilkrzywda e2d712a26c Cap #rss get summaries to one mesh packet (v0.20.1)
CI / build + unit (pull_request) Successful in 42s
Replies >~190 chars fragment into fire-and-forget packets recipients
often never receive; old prompt produced ~326-334 char replies.

Prompt now asks for at most 3 short bullet lines, each under 45 chars,
bullets only (verified live: exactly 3 compliant bullets, ~110 chars).
fitOnePacket() backstop cuts any over-length reply at complete-bullet
boundaries so delivery is one packet even if the model ignores the prompt.

maxTokens 200 -> 2500 on the summary chat(): empirically verified that
this server's reasoning tokens count against max_tokens (cap 150 ->
completion 150 of which 105 were reasoning; cap 500 fully consumed by
reasoning, zero visible content) and thinking is uncontrollable
server-side (/no_think, chat_template_kwargs.enable_thinking,
thinking_budget all ignored), observed 32..1776 tokens for this task.
2500 covers every observed run + ~80 content tokens, worst case ~42 s
under the 60 s LLM timeout.
2026-09-25 19:22:13 +00:00
kamilkrzywda a8ce04d3f8 Merge pull request 'Add #rss command family with LLM tags and summaries (v0.20.0)' (#41) from feat/rss-command into main
CI / build + unit (push) Successful in 40s
Add #rss command family with LLM tags and summaries (v0.20.0) (#41)
2026-09-25 20:46:56 +02:00
kamilkrzywda eda1cbd125 Add #rss command family with LLM tags and summaries (v0.20.0)
CI / build + unit (pull_request) Successful in 1m15s
#rss subcommands on the command channel: help / add <url> <name> /
remove <name> / tags / sources / get <name|#tag>. Sources are stored in
a new rss_sources table in the existing MESH_DB_PATH file (second
node:sqlite connection, case-insensitive names); feeds parsed with
fast-xml-parser (RSS 2.0 + Atom); AI tags and headline summaries via
injected LlmService.chat() with bounded maxTokens (50/200).

CommandService gains prefix registration so one handler owns the whole
#rss family while #ping stays an exact match; wired in CommandModule's
factory — still no constructor-level cycle, no forwardRef. One dead feed
does not sink a tag summary.
2026-09-25 18:44:06 +00:00
kamilkrzywda 023cd3eef3 Merge pull request 'Add OpenAI-compatible LLM client service (v0.19.0)' (#40) from feat/llm-client into main
CI / build + unit (push) Successful in 40s
Add OpenAI-compatible LLM client service (v0.19.0) (#40)
2026-09-25 20:09:32 +02:00
kamilkrzywda c5a2815c01 Add OpenAI-compatible LLM client service (v0.19.0)
CI / build + unit (pull_request) Successful in 1m14s
LlmService wraps the local OpenAI-compatible server (default
http://192.168.1.70:1111/v1, no auth) with native fetch only — no new
dependencies. listModels() -> data[].id; chat(messages, opts?) ->
choices[0].message.content (non-2xx throws with status + body snippet);
summarize(text) = chat() with a concise-summary system prompt.

Env: LLM_API_BASE / LLM_MODEL (qwen3.8-27b) / LLM_TIMEOUT_MS (60000,
AbortSignal.timeout). Plain feature module wired into app.module.ts;
docs synced (AGENTS.md, README env list).
2026-09-25 18:07:21 +00:00
kamilkrzywda 55e59d4963 Merge pull request 'Route command replies through respond() — same channel, threaded quote (v0.18.1)' (#39) from fix/command-replies-respond into main
CI / build + unit (push) Successful in 1m10s
2026-09-25 19:32:31 +02:00
kamilkrzywda 9d4c9594af Route command replies through MeshService.respond() — same channel, threaded quote (v0.18.1)
CI / build + unit (pull_request) Successful in 42s
2026-09-25 17:30:28 +00:00
kamilkrzywda d375f2a943 Merge pull request 'v0.18.0: channel-2 auto-responder with extensible #command boilerplate' (#38) from feat/channel2-autoresponder into main
CI / build + unit (push) Successful in 1m16s
2026-09-25 19:14:15 +02:00
kamilkrzywda 931b42bb1e Add channel-2 #command auto-responder (v0.18.0)
CI / build + unit (pull_request) Successful in 41s
Event-driven CommandService dispatches exact case-sensitive #command
matches on COMMAND_CHANNEL (default 2) through a single-registration-point
registry; first command #ping -> #pong!. Replies ride COMMAND_CHANNEL:
directed DM back to the sender when present, else broadcast. Failing
handler/send only logs a warn.

DI: CommandModule's factory injects MeshService and attaches itself via
MeshService.attachCommands() — a constructor-level cycle leaves one side
null in NestJS 12's injector (verified), so no forwardRef is used.

Tests 106 -> 121; docs synced (AGENTS.md, MESHTASTIC.md, README).
2026-09-25 17:11:52 +00:00
kamilkrzywda 2b2e494023 Merge pull request 'Multi-channel notify gate + ch0 DM-only filtering (v0.17.0)' (#37) from feat/multi-channel-notify into main
CI / build + unit (push) Successful in 40s
2026-09-25 18:21:16 +02:00
kamilkrzywda 4ae932befe mesh: MESH_NOTIFY_CHANNEL as comma-separated list + ch0 DM-only notify gate; v0.17.0
CI / build + unit (pull_request) Successful in 1m8s
2026-09-25 16:18:07 +00:00
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
42 changed files with 7108 additions and 2087 deletions
+5 -3
View File
File diff suppressed because one or more lines are too long
+154
View File
@@ -1,5 +1,159 @@
# Changelog
## v0.22.0 — 2026-10-13 00:42
- **Phase 2: the service now reaches the Meshtastic device through the MeshMonitor HTTP gateway** (`MeshMonitorClient`) instead of a raw TCP socket + codec. `MeshService` takes an injectable client (`HttpMeshMonitorClient` in production via `MESHMONITOR_API_TOKEN`, `FakeMeshMonitorClient` in tests); a missing token means "no gateway" — the service stays disconnected and sends throw 503. The `net.Socket` connection, frame buffer, codec framing, idle watchdog and periodic reconnect are all gone; all `/api/*` REST and MCP endpoint shapes, the notifier, the command auto-responder, the battery alerts and the weather job behave as before.
- Poll loops replace the socket: status every 15 s (5 s retries while down) gates everything; on first success the service syncs nodes → channels → telemetry → messages (the message poll takes a seen-set baseline so pre-existing traffic is not re-processed) and starts the loops: messages every `MESH_POLL_MS` (default 1 s) in `{limit: 50}` newest-first windows, nodes every 30 s, channels every 5 min, telemetry every 60 s (long-format rows are grouped into per-packet records and deduped).
- Outbound: broadcasts ride `send({ text, channel })` on the pinned send channel (default 3, `MESH_SEND_CHANNEL`); DMs ride `send({ text, toNodeId: '!hex' })` on the primary channel in mesh terms. Gateway-rejected sends are recorded as failed tx history entries; HTTP 413 → 400 BadRequest, anything else → 503 ServiceUnavailable. `respond()` semantics are unchanged (rx → DM back to the sender unless the original was a broadcast, tx → its destination, injected → broadcast; always threaded via `replyId`).
- Documented degradations vs the raw socket: `GET /api/device` is degraded (no device metadata / local+module config — only the firmware version when present in the node record); there is no idle-watchdog/periodic-reconnect (steady polling replaces it); world state (nodes/channels/telemetry) is not cleared when the status drops — each refreshes on its own interval.
- **Test migration:** the `MeshService`, MCP controller, command auto-responder and battery-alert specs now drive the real service through `FakeMeshMonitorClient` — the fake TCP bridge is gone (`src/mesh/fake-bridge.ts` deleted, zero imports remain). The fake client's test API was widened to match: the constructor takes the source identity (`{ connected, nodeNum, longName, shortName }`), auto-seeds the bot's own node (the gateway always reports it), `pushNode` upserts by num, plus the existing `pushInbound`/`setDeliveryState`/`clearTraffic`/`sends[]` mirror. Suite: 17 files / 245 tests green (baseline 234).
- **Docker:** compose drops `MESH_HOST`/`MESH_PORT` and adds `MESHMONITOR_API_TOKEN` (from `.env`, never committed), `MESHMONITOR_API_BASE` (default `https://meshmonitor.kkhost.pl/api/v1`), `MESHMONITOR_SOURCE` (default `default`) and `MESHMONITOR_TIMEOUT_MS` (default 15000). The gateway uses a valid Let's Encrypt certificate, so normal TLS verification applies — no `NODE_TLS_REJECT_UNAUTHORIZED` workaround.
## v0.21.12 — 2026-10-06 08:01
- Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts.
## v0.21.11 — 2026-10-05 14:47
- Migrated the opencode notifier to the v2 API routes: discovery is now `GET {api}{proxyPath}/api/session` (a `{ data, cursor }` envelope; sessions filtered by `location.directory`, most-recent `time.updated` wins) and send is now `POST {api}{proxyPath}/api/session/{sid}/prompt` with a `{text}` body, instead of the v1 `GET /session` + `POST /session/{id}/message` (`{parts:[{type:"text",text}]}`) routes. CodeNomad's per-workspace instance proxy was upgraded and now only serves opencode v2 `/api/*` — every v1 `/session/…` call 403s, which was breaking all notifications. v2 returns 2xx immediately (no longer holds the response until the turn finishes), so the 15 s read-timeout-as-delivered branch is kept only as a harmless safety net.
## v0.21.10 — 2026-09-30 05:46
- Container now runs with `TZ=Europe/Warsaw` (new line in the bot service's `environment:` block, hardcoded like MESH_HOST/MESH_PORT): scheduled jobs and log timestamps use local Polish time instead of UTC — the weather cron (`WEATHER_CRON=0 6,12,18 * * *`) previously fired at 06/12/18 UTC = 08/14/20 CEST. DST-aware via the IANA zone (CEST +2 / CET +1). Deployment-config only — no application code changed; takes effect on the next `docker compose up -d --build`.
## v0.21.9 — 2026-09-29 20:56
- New `#loser` command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to `longName`/`shortName`, falling back to the `@!xxxxxxxx` mention when unknown. Three reply shapes: worst delivery line (`worst delivery: <name> (@!hex) - N failed / M delivered`), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to `#ping`; listed in `#help`.
- Tests: 175 → 180 — new `#loser` specs cover all three reply cases, broadcast/pending exclusion, the unknown-node `@!hex` fallback, and the failure-ratio tie-break; the `#help` spec now also asserts `#loser` is listed.
## v0.21.8 — 2026-09-27 07:13
- Fixed `npm test` exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit `read ECONNRESET`. Those test-owned sockets had no `'error'` handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. `startBridge()` now attaches a no-op `'error'` handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — `fake-bridge.ts` is imported by specs only; no runtime diff, no deploy needed.
## v0.21.7 — 2026-09-27 06:50
- Fixed double registration of scheduled jobs: `ScheduleModule.forRoot()` was called from both `WeatherModule` and `BatteryAlertModule`, so Nest created two ScheduleExplorers, each scanning all providers — every @Cron/@Interval job (hourly weather broadcast, per-minute battery check) fired twice. Removed the call from `battery.module.ts`; the single registration in `weather.module.ts` remains.
- Tests: 174 → 175 — new regression spec (`src/schedule-singleton.spec.ts`) asserts `ScheduleModule.forRoot(` appears exactly once across all `src/**/*.module.ts`.
## v0.21.6 — 2026-09-26 10:37
- New `#help` command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — `#ping`, the `#rss help/add/remove/get/sources/tags` family. Registered in the CommandService constructor next to `#ping`.
- Tests: 173 → 174 — `#help` dispatches through respond() exactly once with text containing both `#ping` and `#rss`. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.)
## v0.21.5 — 2026-09-26 10:26
- Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
- Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin `MESH_MULTI_SEND_DELAY_MS=0` in `beforeEach` so they stay fast.
## v0.21.4 — 2026-09-26 09:51
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
## v0.21.3 — 2026-09-26 09:31
- Idle timeout default lowered from 10 min to **1 min** (`MESH_IDLE_TIMEOUT_MS` fallback `600000` → `60000`) — the v0.21.2 frame-based watchdog works in production (it fired today and the reconnect delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps killing inbound RX within ~20 min of a fresh sync, so a shorter detection window is wanted.
- New **lazy periodic autoreconnect**: `MESH_RECONNECT_INTERVAL_MS` (new env, default **300000** = 5 min, read at construction) — an interval that unconditionally logs ("periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed") and destroys the socket so the existing close→scheduleReconnect loop re-establishes with a fresh sync on schedule. When not connected at tick time, destroy is a no-op and the retry loop owns recovery (no special-casing). Started in `onModuleInit`, cleared in `onModuleDestroy`.
- Tests: 171 → 172 — periodic reconnect observed end-to-end with an overridden short interval (fake bridge disconnect + re-establish); existing watchdog tests pass unchanged, comments updated to the new 60 s default.
## v0.21.2 — 2026-09-26 08:47
- Idle watchdog now tracks DECODED FRAMES, not raw bytes (fixes a second production zombie that slipped past v0.21.1): text+telemetry RX was dead for >1 h while TX worked and the watchdog never fired — /proc/net/snmp showed InSegs delta = 2 segments per 30 s, i.e. the bridge peer keeps dribbling small NON-frame bytes (~every 15 s) that kept resetting `lastPacketAt` on every socket chunk even though zero valid Meshtastic frames decoded. The `'data'` handler no longer updates `lastPacketAt` per chunk; it is updated only when `extractFrames` yields ≥1 valid packet (the reset in the `'connect'` handler is unchanged), so a stream that decodes nothing trips the watchdog after `MESH_IDLE_TIMEOUT_MS` even while raw bytes keep arriving.
- Buffer-overflow backstop: if the undecoded frame buffer exceeds `MESH_MAX_FRAME_BUFFER` (new env, default **256000** = 250 KB) the service logs ERROR ("frame buffer overflow — stream desync, forcing reconnect") and destroys the socket → existing close→scheduleReconnect self-heals a desynced stream. The undecoded buffer is also reset on every (re)connect so leftover garbage from a dead session can't re-trigger the backstop or corrupt the first frames of a healthy new stream.
- Tests: 169 → 171 — raw non-frame bytes do NOT keep the watchdog alive (garbage-only stream forces disconnect + reconnect, exactly today's incident shape), and an undecoded chunk over `MESH_MAX_FRAME_BUFFER` triggers destroy + a fresh session that decodes normally. The existing valid-frames-reset-the-clock test now exercises frame-based tracking unchanged.
## v0.21.1 — 2026-09-26 06:47
- Bridge zombie-connection fix (production incident: outbound sends kept working ~10 h while inbound packets stopped entirely and `/api/status` stayed `connected:true`). New **idle watchdog**: the service tracks `lastPacketAt`, updated on EVERY chunk received from the bridge in the lowest-level socket `'data'` path (any frame kind, decodable or not) and reset when a connection successfully starts. A 30 s interval checks: if connected AND no inbound traffic for `MESH_IDLE_TIMEOUT_MS` (new env, default **600000** = 10 min), it logs WARN ("no bridge traffic for X ms — forcing reconnect") and destroys the socket — the EXISTING connect/retry loop (`MESH_RECONNECT_MS`) then re-establishes with a fresh sync. No changes to connect-error retry/backoff behavior; this only adds detection for established-but-silent connections.
- TCP keepalive on the bridge socket: `socket.setKeepAlive(true, 30_000)` — cheap insurance against dead peers at the TCP level, complementing the application-level watchdog.
- Tests: 165 → 169 — silence past an overridden timeout forces disconnect + reconnect (fake bridge), inbound traffic keeps resetting the idle clock so no false trigger, default timeout does not fire in a test window, and `setKeepAlive(true, 30_000)` is asserted on the socket.
## v0.21.0 — 2026-09-25 19:59
- `#rss get <name|#tag>` now replies with **3–5 SEPARATE mesh messages** instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet.
- New summarizer prompt asks for exactly 3-5 lines, one item per line, format `<short header>: <one or two short sentences>` with a ≤20-char header (e.g. `Culture: Museum opens new wing`), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1).
- The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a `:` header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent.
- Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (`fitOneLine`), so every item fits one packet even if the model ignores the prompt. `fitOnePacket()` (whole-reply ≤190) is gone — it no longer applies to joined text.
- CommandService now sends array replies line by line: each element goes through the same routing as a single reply (`respond(id, line)` threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command.
- LLM failure path unchanged: single `summary failed` reply via respond(). `#rss get #tag` gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged.
- Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through `handle()`, direct `parseSummaryItems`/`fitOneLine` specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback).
## v0.20.1 — 2026-09-25 19:20
- `#rss get` summaries now fit ONE mesh packet (≤190 chars). Mesh fragments longer replies into fire-and-forget packets that recipients frequently never receive, and the old prompt ("a few short one-line highlights") produced ~326-334 char replies.
- The summary prompt now asks for "at most 3 short bullet lines, each under 45 characters … bullets only - no intro, no other text" (verified against the live LLM: exactly 3 compliant bullets, ~110 chars). A `fitOnePacket()` backstop additionally cuts any over-length reply at complete-bullet boundaries so delivery is one packet even if the model ignores the prompt.
- The summary `chat()` call now uses an explicit `maxTokens = 2500` (was 200). Empirically verified on this server: qwen3.8-27b emits `reasoning_content` and those tokens COUNT against max_tokens (cap 150 → completion_tokens 150, of which 105 were reasoning, finish_reason 'length'; a cap of 500 was fully consumed by reasoning with zero visible content). Thinking length for this task is unbounded and uncontrollable server-side (`/no_think`, `chat_template_kwargs.enable_thinking` and `thinking_budget` are all ignored) — observed 32..1776 tokens across runs. 2500 covers every observed run plus ~80 content tokens, and keeps the worst case (~42 s at ~60 tok/s) under the 60 s LLM timeout. The old cap of 200 guaranteed empty replies whenever reasoning exceeded it.
- Tests: 154 → 158 — prompt shape + maxTokens asserted on the summary call, over-length reply cut through `handle()`, and direct `fitOnePacket` specs (pass-through at/under 190, bullet-boundary cut, hard-cut fallback for non-bulleted paragraphs).
## v0.20.0 — 2026-09-25 18:40
- New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add <url> <name>` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove <name>`, `#rss tags` (all distinct tags), `#rss sources`, `#rss get <name>` and `#rss get #<tag>` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`.
- Storage: new `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file (default ./data/mesh-bot.db) via a second node:sqlite connection opened by `RssModule`'s factory — no new DB file. Names are matched case-insensitively everywhere (`COLLATE NOCASE`).
- Feed parsing: new dependency `fast-xml-parser` (the only one added for this feature) — RSS 2.0 (`<item><title>/<link>`) and Atom (`<entry><title>`, link as `href` attribute); CDATA titles supported; malformed input or no usable items → "not a valid feed".
- LLM: `RssService` injects `LlmService` (plain DI, one-way — `RssModule` imports `LlmModule`) and uses `chat()` directly with explicit maxTokens (50 for tags, 200 for summaries) to keep mesh replies short. Tag output is sanitized (lowercase, no #, whitespace → '-', deduped).
- Dispatch: `CommandService` gains prefix registration (`registerPrefix(name, fn)`) alongside exact matches — one handler owns the whole `#rss` family while `#ping` stays an untouched exact match; exact names win over prefixes, and a name merely starting with the prefix (e.g. `#rssfoo`) does not dispatch. Wired in `CommandModule`'s factory (`inject: [MeshService, RssService]`) — still no constructor-level cycle, no forwardRef.
- Tests: 128 → 153 specs — store CRUD against a real temp SQLite file (incl. second-connection persistence), feed parser fixtures (RSS 2.0 / Atom / malformed / empty titles), handler behavior with mocked LLM + fetch (help, add success/LLM-fail/invalid-url/duplicate/non-2xx/unparseable, remove found/missing, tags/sources empty+populated, get by name/by tag across sources/dead-feed resilience/unknown name+tag), and `#rss` prefix dispatch through respond().
## v0.19.0 — 2026-09-25 18:03
- New LLM client module (`src/llm`): `LlmService` speaks the OpenAI-compatible chat-completions API of the local server at `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`, no auth) using native fetch only — no new dependencies. Plain feature module (`LlmModule`), wired into `app.module.ts`; no Mesh/Schedule dependency.
- Methods: `listModels()` (GET `/models` → `data[].id` strings), `chat(messages, opts?)` (POST `/chat/completions` with `{ model, messages, ...opts }` → `choices[0].message.content`; non-2xx throws an Error carrying the HTTP status and a short body snippet; malformed responses without content throw too), and `summarize(text)` — thin wrapper over `chat()` with system prompt "Summarize the following text concisely." (the stated primary use).
- Env: `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`), `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default `60000` — the local 27B model can be slow; applied via `AbortSignal.timeout`).
- Tests: +4 specs — listModels parses ids, chat passes model/messages/opts through and returns content, chat throws on non-2xx with status in the message, summarize sends system prompt + user text.
## v0.18.1 — 2026-09-25 17:30
- Command replies now ride `MeshService.respond(id, reply)` instead of direct `sendText` routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the **original message's channel** with `replyId = id`, so the recipient app renders it as a threaded quote of the triggering packet. `COMMAND_CHANNEL` is now only the trigger/listen gate — the reply channel comes from the record.
- Defensive fallback: when the trigger has no packet id (never happens in production — `MeshService` always passes `pkt.id`), the legacy sendText routing on `COMMAND_CHANNEL` (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes.
- Tests: 121 → 124 specs — unit cases now mock `mesh.respond` (id present → `respond(id, reply)` with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: `#ping` broadcast on ch2 → `#pong!` broadcast on ch2 with `replyId` = ping packet id, and `#ping` DM → `#pong!` DM to the sender on ch2 with `replyId` set. All gate tests (case/whitespace/channel/env override) kept.
- The wire-decode test helper `outboundPackets()` moved from `mesh.service.spec.ts` into `fake-bridge.ts` so both specs share it.
- Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (`COMMAND_CHANNEL` still exists, now meaning trigger channel only).
## v0.18.0 — 2026-09-25 17:08
- New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`.
- Reply routing v1: directed DM back to the sender when present, else broadcast — always on `COMMAND_CHANNEL`, via the existing `MeshService.sendText(to, text, undefined, channel)` path. No threading/`replyId` in v1. A failing handler or send only logs a warn; it never crashes the app.
- Wiring: `MeshService` gains an optional third constructor param (`commands?`) plus a public `attachCommands()` — the rx handler calls `this.commands?.onRx(channel, text, from, id)` right after `notifyRx()`, fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so `CommandModule` provides `CommandService` via a factory that injects `MeshService` and attaches itself as a side effect — no `forwardRef`, no module cycle.
- Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, `COMMAND_CHANNEL` override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (`#ping` on ch2 answered with a directed `#pong!` tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances.
- Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains `COMMAND_CHANNEL`.
## v0.17.0 — 2026-09-25 16:18
- The mesh→agent notify gate is now a comma-separated multi-channel list: `MESH_NOTIFY_CHANNEL` (default `3`) parses to channel indices, each entry trimmed, empty/invalid entries ignored (`"3,0"` → `[3, 0]`).
- ch0 DM-only filtering: ch0 is the shared default channel where other users talk — only directed messages (both sender and recipient present) notify there; ch0 broadcasts and undirected traffic are ignored. Synthetic injections (`POST /api/messages`, `injectMessage`) ride ch0 by design and stay exempt from the rule, so they still notify when 0 is in the list.
- `notifyRx()` gains optional params: `to?` (the packet's destination, now passed through from the rx handler) and an `injected` flag; the field is renamed `notifyChannel` → `notifyChannels`. Self-echo filter unchanged.
- Tests: 102 → 106 specs — multi-list match/non-match, ch0 broadcast ignored even with 0 in the list, ch0 directed DM notified, ch0 injection notified via a multi-list, invalid env entries ignored.
- Docs: AGENTS.md + MESHTASTIC.md "Notifications" describe the list semantics and the ch0 DM-only rule; README env bullet/forwarded/notifier lines and the controller's `GET /api/forwarded` description kept in sync. Local `.env` (gitignored) now `MESH_NOTIFY_CHANNEL=3,0`.
## v0.16.0 — 2026-09-25 15:49
- 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.
+3 -1
View File
@@ -22,7 +22,9 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
- All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel.
- Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted.
- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) on that channel get an automatic reply routed through `MeshService.respond()` — DM back to the original sender, broadcast when the original was a broadcast — riding the original message's channel as a threaded quote (`replyId` = the trigger packet id). Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything.
## Notifications (mesh → agent)
- 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 a channel in the notify list (`MESH_NOTIFY_CHANNEL`, comma-separated indices, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
- Channel 0 is the shared default channel where other users talk: it carries real DMs when both sender and recipient are present, and the bot listens there too — but only directed messages trigger notifications; ch0 broadcasts are ignored. Synthetic injected messages (channel 0) are exempt from this rule.
+19 -10
View File
@@ -27,10 +27,12 @@ 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">, "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 in the `MESH_NOTIFY_CHANNEL` list (ch0: directed DMs only), not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
@@ -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?, 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 a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list, it pushes it to the mesh-bot folder's live opencode session (Basic auth). On ch0 only directed DMs are pushed — broadcasts there are ignored. The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
- `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,11 +120,14 @@ 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`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified)
- `COMMAND_CHANNEL` (default `2`) — channel index of the auto-responder: exact `#command` texts on that channel get an automatic reply (directed back to the sender, else broadcast); currently only `#ping` → `#pong!`
- `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation)
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
- `PORT` (default `3000`) — HTTP listen port
- `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`) — OpenAI-compatible LLM server base URL used by the in-process `LlmService` (`src/llm`; no auth)
- `LLM_MODEL` (default `qwen3.8-27b`) — model id sent to `/chat/completions`
- `LLM_TIMEOUT_MS` (default `60000`) — per-request timeout for LLM calls (the local 27B model can be slow)
Note: the serial bridge accepts only ONE client at a time — if another app is
connected to the radio, this bot will not get a connection.
+10 -14
View File
@@ -2,12 +2,19 @@ services:
bot:
build: .
environment:
MESH_HOST: 192.168.1.4
MESH_PORT: '4403'
TZ: Europe/Warsaw
MESHMONITOR_API_TOKEN: ${MESHMONITOR_API_TOKEN:-}
MESHMONITOR_API_BASE: ${MESHMONITOR_API_BASE:-https://meshmonitor.kkhost.pl/api/v1}
MESHMONITOR_SOURCE: ${MESHMONITOR_SOURCE:-default}
MESHMONITOR_TIMEOUT_MS: ${MESHMONITOR_TIMEOUT_MS:-15000}
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 +26,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()
+123 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mesh-bot",
"version": "0.11.3",
"version": "0.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mesh-bot",
"version": "0.11.3",
"version": "0.19.0",
"license": "UNLICENSED",
"dependencies": {
"@bufbuild/protobuf": "^2.15.0",
@@ -18,6 +18,7 @@
"@nestjs/schedule": "^12.0.2",
"@nestjs/swagger": "^12.0.2",
"@scalar/nestjs-api-reference": "^1.2.21",
"fast-xml-parser": "^5.11.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -1148,6 +1149,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.151.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
@@ -2336,6 +2349,18 @@
"node": ">=14"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -3279,6 +3304,45 @@
"fast-string-width": "^3.0.2"
}
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3830,6 +3894,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -4848,6 +4924,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5503,6 +5594,21 @@
"node": ">=4"
}
},
"node_modules/strnum": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
@@ -6147,6 +6253,21 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yoctocolors": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.13.0",
"version": "0.22.0",
"description": "",
"author": "",
"private": true,
@@ -30,6 +30,7 @@
"@nestjs/schedule": "^12.0.2",
"@nestjs/swagger": "^12.0.2",
"@scalar/nestjs-api-reference": "^1.2.21",
"fast-xml-parser": "^5.11.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
+11 -1
View File
@@ -1,9 +1,19 @@
import { Module } from '@nestjs/common';
import { BatteryAlertModule } from './battery/battery.module.js';
import { CommandModule } from './commands/command.module.js';
import { LlmModule } from './llm/llm.module.js';
import { MeshModule } from './mesh/mesh.module.js';
import { RssModule } from './rss/rss.module.js';
import { WeatherModule } from './weather/weather.module.js';
@Module({
imports: [MeshModule, WeatherModule, BatteryAlertModule],
imports: [
MeshModule,
WeatherModule,
BatteryAlertModule,
CommandModule,
LlmModule,
RssModule,
],
})
export class AppModule {}
+1 -2
View File
@@ -1,10 +1,9 @@
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],
imports: [MeshModule],
providers: [BatteryAlertService],
exports: [BatteryAlertService],
})
+95 -54
View File
@@ -1,10 +1,8 @@
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 { BROADCAST_ADDR } from '../mesh/codec.js';
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import {
BatteryAlertService,
formatAlert,
@@ -33,9 +31,10 @@ function makeService(
}
beforeEach(() => {
// defaults under test: threshold 20, cooldown 6 h
// 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', () => {
@@ -79,11 +78,13 @@ describe('BatteryAlertService check threshold', () => {
await svc.check();
// <= choice: exactly 20 qualifies too — both in ONE batched message
// <= 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,
);
});
@@ -99,6 +100,8 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 50%',
undefined,
2,
);
} finally {
delete process.env.BATTERY_ALERT_THRESHOLD;
@@ -114,6 +117,47 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).not.toHaveBeenCalled();
});
it('ignores a 0% reading (invalid/no battery) even though 0 <= threshold', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
// 0% must not alert, but a genuinely-low 5% node in the same batch must
const svc = makeService([node(1, 0), node(2, 5)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
// only the 5% node — the 0% node is excluded entirely
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000002 5%',
undefined,
2,
]);
});
it('ignores a lone 0% reading (no broadcast at all)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 0)], sendText); // only an invalid 0% reading
await svc.check();
expect(sendText).not.toHaveBeenCalled();
});
it('still alerts a genuine 1% reading (lowest valid value)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 1)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 1%',
undefined,
2,
]);
});
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(
@@ -127,9 +171,31 @@ describe('BatteryAlertService check threshold', () => {
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)],
@@ -197,64 +263,39 @@ describe('BatteryAlertService check cooldown', () => {
});
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
it('broadcasts the alert on the pinned send channel (index 3 by default)', 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 path: "test" (index 3)
const mesh = new RealMeshService();
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: 3994646728,
});
client.pushNode({
num: 0xabc,
longName: 'Alpha',
batteryLevel: 5,
lastHeard: 1_760_000_000,
});
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
const mesh = new RealMeshService(undefined, undefined, client);
try {
mesh.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && mesh.status().connected,
);
await mesh.pollStatus();
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 [];
// one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3)
expect(client.sends).toHaveLength(1);
expect(client.sends[0]).toMatchObject({
text: '⚠️ battery low: @!00000abc (Alpha) 5%',
toNodeId: undefined, // broadcast — not a DM
channel: 2,
replyId: undefined,
});
};
await waitFor(() => readOutbound().length === 1);
const pkt = readOutbound()[0];
expect(pkt.channel).toBe(3); // "test" — never the primary channel (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.MESHMONITOR_API_BASE;
delete process.env.MESH_SEND_CHANNEL;
mesh.onModuleDestroy();
bridge.close();
}
});
});
+13 -2
View File
@@ -33,6 +33,11 @@ export class BatteryAlertService {
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>();
@@ -55,6 +60,7 @@ export class BatteryAlertService {
private shouldAlert(node: NodeSummary, now: number): boolean {
if (node.batteryLevel === undefined) return false; // no battery reported — skip
if (node.batteryLevel <= 0) return false; // 0/invalid reading — no battery wired or misconfigured node, not a real low-battery alert
// <= 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);
@@ -62,8 +68,13 @@ export class BatteryAlertService {
}
private async alert(nodes: NodeSummary[]): Promise<void> {
// one broadcast per cycle for the whole batch — never one message per node; rides the pinned send channel like every other bot send (MeshService.sendText)
await this.mesh.sendText(BROADCAST_ADDR, formatAlert(nodes));
// 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);
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { MeshModule } from '../mesh/mesh.module.js';
import { MeshService } from '../mesh/mesh.service.js';
import { RssModule } from '../rss/rss.module.js';
import { RssService } from '../rss/rss.service.js';
import { CommandService } from './command.service.js';
@Module({
imports: [MeshModule, RssModule], // one-way only — the MeshService reverse edge is wired in the factory below, not via DI
providers: [
{
provide: CommandService,
useFactory: (mesh: MeshService, rss: RssService) => {
const commands = new CommandService(mesh, rss);
// break the cycle: MeshService can't inject us (its own factory would deadlock on this edge), so attach ourselves once we exist — mesh is guaranteed created here because it's our injected dependency
mesh.attachCommands(commands);
return commands;
},
inject: [MeshService, RssService],
},
],
exports: [CommandService],
})
export class CommandModule {}
+528
View File
@@ -0,0 +1,528 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import type {
MessageRecord,
MeshService,
NodeSummary,
} from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { FakeMeshMonitorClient } from '../mesh/meshmonitor/fake-mesh-monitor-client.js';
import type { LlmService } from '../llm/llm.service.js';
import { RssService } from '../rss/rss.service.js';
import { CommandService } from './command.service.js';
// local copies — the fake-bridge constants these specs used are gone with Phase 2
const KAMIL_NUM = 123456789;
const KAMIL_ID = '!075bcd15';
function makeService(
sendText?: ReturnType<typeof vi.fn>,
respond?: ReturnType<typeof vi.fn>,
): [CommandService, MeshService] {
const mesh = {
sendText: sendText ?? vi.fn(),
respond: respond ?? vi.fn().mockResolvedValue({ id: 1 }),
} as unknown as MeshService;
return [new CommandService(mesh), mesh];
}
beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
});
describe('CommandService.onRx', () => {
it('replies #ping via respond() — threaded quote of the trigger packet, no direct sendText', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(7, '#pong!');
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('replies #help with a short single-message list of all commands', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#help', 42, 7);
expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line
const text = mesh.respond.mock.calls[0][1];
expect(text).toContain('#ping');
expect(text).toContain('#loser');
expect(text).toContain('#rss');
});
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('falls back to a broadcast sendText on COMMAND_CHANNEL when the trigger has no packet id (no sender)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping'); // no from, no id — defensive path
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'#pong!',
undefined,
2,
);
});
it('ignores the same text on other channels (ch0 and ch3)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(0, '#ping', 42);
await svc.onRx(3, '#ping', 42);
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('ignores non-command text on the command channel', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, 'hello there', 42);
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('matches command names exactly and case-sensitively', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#Ping', 42);
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('trims surrounding whitespace before matching', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, ' #ping ', 42, 9);
expect(mesh.respond).toHaveBeenCalledWith(9, '#pong!');
});
it('honors the COMMAND_CHANNEL env override', async () => {
process.env.COMMAND_CHANNEL = '5';
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // old default no longer triggers
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
await svc.onRx(5, '#ping', 42, 8);
expect(mesh.respond).toHaveBeenCalledWith(8, '#pong!');
});
it('sends nothing when a handler returns void', async () => {
const [svc, mesh] = makeService();
svc.register('silent', () => undefined);
await svc.onRx(2, '#silent', 42);
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('is extensible: a second registered command dispatches through the same path', async () => {
const [svc, mesh] = makeService();
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
await svc.onRx(2, '#status', 99, 5);
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
});
it('sends each line of an array reply as its own threaded respond() to the same packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
await svc.onRx(2, '#multi', 42, 9);
expect(mesh.respond).toHaveBeenCalledTimes(2);
expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']);
expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
process.env.MESH_MULTI_SEND_DELAY_MS = '50'; // short so the test stays fast
let sends = 0;
const respond = vi.fn(async () => {
sends++;
return { id: 1 };
});
vi.useFakeTimers();
try {
const [svc, mesh] = makeService(undefined, respond);
svc.register('multi3', () => ['a: one', 'b: two', 'c: three']);
const p = svc.onRx(2, '#multi3', 42, 9);
// flush microtasks without advancing time — the first line must already be out (no delay before it)
await vi.advanceTimersByTimeAsync(0);
expect(sends).toBe(1);
// each subsequent line waits exactly MESH_MULTI_SEND_DELAY_MS after the previous one landed
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(2);
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(3);
await p;
expect(mesh.respond).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
delete process.env.MESH_MULTI_SEND_DELAY_MS;
}
});
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender
expect(mesh.sendText).toHaveBeenCalledTimes(2);
expect(mesh.sendText.mock.calls[0]).toEqual([
42,
'one: first item',
undefined,
2,
]);
expect(mesh.respond).not.toHaveBeenCalled();
});
it('swallows a failing handler (a bad command must not crash the app)', async () => {
const [svc, mesh] = makeService();
svc.register('boom', () => {
throw new Error('kaboom');
});
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('swallows a failing respond (unknown id / bridge down) without throwing', async () => {
const [svc] = makeService(
undefined,
vi.fn().mockRejectedValue(new Error('no message with id 7')),
);
await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined();
});
it('swallows a failing send (mesh down) without throwing', async () => {
const [svc] = makeService(
vi.fn().mockRejectedValue(new Error('no bridge')),
);
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path
});
});
describe('#loser', () => {
const tx = (
id: number,
to: number,
status: MessageRecord['status'],
): MessageRecord => ({
id,
dir: 'tx',
to,
text: 'hi',
channel: 3,
at: Date.now(),
status,
});
const makeLoserMesh = (
messages: MessageRecord[],
nodes: NodeSummary[],
): [CommandService, MeshService] => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
messages: () => messages,
nodes: async () => nodes,
} as unknown as MeshService;
return [new CommandService(mesh), mesh];
};
it('reports the node with the most failures — named via longName, broadcasts and pending sends excluded', async () => {
const messages = [
tx(10, BROADCAST_ADDR, 'failed'), // firmware quirk: a broadcast NACK is never a real destination
tx(9, 222, 'failed'), // Alice: 3 failed / 0 delivered → loser
tx(8, 111, 'delivered'), // Bob: 1 failed / 2 delivered
tx(7, 222, 'failed'),
tx(6, 111, 'failed'),
tx(5, 333, 'pending'), // no final status yet — ignored
tx(4, 222, 'failed'),
{
id: 3,
dir: 'rx',
from: 999,
text: 'hello',
channel: 0,
at: Date.now(),
},
];
const nodes = [
{ num: 111, shortName: 'Bob', lastHeard: null },
{ num: 222, longName: 'Alice', lastHeard: null },
];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: Alice (@!000000de) - 3 failed / 0 delivered',
);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('says no failed sends when the directed history is all delivered', async () => {
const messages = [tx(2, 5, 'delivered'), tx(1, 6, 'delivered')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'no failed sends yet - everyone delivers :)',
);
});
it('explains the directed-only rule when there is no final-status data at all', async () => {
const messages = [tx(2, BROADCAST_ADDR, 'pending'), tx(1, 5, 'pending')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'not enough data yet - only directed sends count (broadcasts have no acks)',
);
});
it('falls back to the @!hex mention when the worst node has no known name', async () => {
const messages = [tx(1, 600, 'failed')];
const nodes = [{ num: 5, longName: 'Someone Else', lastHeard: null }];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000258 - 1 failed / 0 delivered',
);
});
it('tie-breaks equal failure counts by the worse failure ratio', async () => {
const messages = [
// node 7: 1 failed / 9 delivered (ratio 0.1) — not the loser
tx(16, 8, 'failed'), // node 8: 1 failed / 1 delivered (ratio 0.5) → loser
tx(15, 8, 'delivered'),
tx(14, 7, 'failed'),
...Array.from({ length: 9 }, (_, i) =>
tx(i + 1, 7, 'delivered' as const),
),
];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000008 - 1 failed / 1 delivered',
);
});
});
describe('#rss prefix dispatch', () => {
let dir: string | undefined;
const makeRss = (): RssService => {
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-cmd-rss-'));
return new RssService(
{ chat: vi.fn() } as unknown as LlmService,
new DatabaseSync(join(dir, 'test.db')),
);
};
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
it('routes #rss subcommands through respond() like any command reply', async () => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
} as unknown as MeshService;
const svc = new CommandService(mesh, makeRss());
await svc.onRx(2, '#rss sources', 42, 9);
expect(mesh.respond).toHaveBeenCalledWith(9, 'no sources yet');
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('routes bare #rss to the help text via respond()', async () => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
} as unknown as MeshService;
const svc = new CommandService(mesh, makeRss());
await svc.onRx(2, '#rss', 42, 9);
expect(mesh.respond).toHaveBeenCalledTimes(1);
expect(String(mesh.respond.mock.calls[0][1])).toContain(
'#rss add <url> <name>',
);
});
it('leaves exact commands untouched and does not partial-match prefix names', async () => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
} as unknown as MeshService;
const svc = new CommandService(mesh, makeRss());
await svc.onRx(2, '#ping', 42, 3);
expect(mesh.respond).toHaveBeenCalledWith(3, '#pong!');
await svc.onRx(2, '#rssfoo', 42, 4); // starts with 'rss' but is not the family — no partial prefix match
expect(mesh.respond).toHaveBeenCalledTimes(1);
});
it('does nothing for #rss when no RssService was wired (legacy construction)', async () => {
const [svc, mesh] = makeService(); // rss omitted
await svc.onRx(2, '#rss sources', 42, 9);
expect(mesh.respond).not.toHaveBeenCalled();
expect(mesh.sendText).not.toHaveBeenCalled();
});
});
describe('MeshService wiring (event-driven trigger)', () => {
const MY_NUM = 3994646728;
const T0 = 1_760_000_000_000;
// wire the responder exactly like command.module.ts does in production,
// against the fake gateway client; `settle` lets the fire-and-forget
// onRx → respond → sendText microtask chain finish
const makeWiredService = async (
client: FakeMeshMonitorClient,
): Promise<RealMeshService> => {
const svc = new RealMeshService(undefined, undefined, client);
svc.attachCommands(new CommandService(svc));
svc.onModuleInit();
await svc.pollStatus();
return svc;
};
const settle = () => new Promise((r) => setTimeout(r, 10));
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: MY_NUM,
});
const svc = await makeWiredService(client);
try {
// rx DM from KAMIL to us on channel 2 (packet id 7)
client.pushInbound({
packetId: 7,
fromNum: KAMIL_NUM,
fromId: KAMIL_ID,
toNum: MY_NUM,
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
// history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1)
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: '#pong!',
channel: 0,
});
// client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply)
expect(client.sends).toHaveLength(1);
expect(client.sends[0]).toMatchObject({
text: '#pong!',
toNodeId: KAMIL_ID,
channel: undefined,
replyId: 7,
});
} finally {
svc.onModuleDestroy();
}
});
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: MY_NUM,
});
const svc = await makeWiredService(client);
try {
// rx broadcast from KAMIL on channel 2 (packet id 7)
client.pushInbound({
packetId: 7,
fromNum: KAMIL_NUM,
fromId: KAMIL_ID,
toId: 'broadcast',
text: '#ping',
channel: 2,
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
// broadcast original → broadcast reply, on the same channel
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: BROADCAST_ADDR,
text: '#pong!',
channel: 2,
});
expect(client.sends).toHaveLength(1);
expect(client.sends[0]).toMatchObject({
text: '#pong!',
toNodeId: undefined,
channel: 2,
replyId: 7, // threaded quote of the ping packet (id 7)
});
} finally {
svc.onModuleDestroy();
}
});
it('does not answer #ping arriving on a non-command channel', async () => {
const client = new FakeMeshMonitorClient({
connected: true,
nodeNum: MY_NUM,
});
const svc = await makeWiredService(client);
try {
client.pushInbound({
packetId: 7,
fromNum: KAMIL_NUM,
fromId: KAMIL_ID,
toNum: MY_NUM,
text: '#ping',
channel: 3, // not the command channel (2)
portnum: 1,
at: T0,
});
await svc.pollMessages();
await settle();
// only the rx record — no reply anywhere
expect(svc.messages()[0]).toMatchObject({
dir: 'rx',
text: '#ping',
channel: 3,
});
expect(client.sends).toEqual([]);
} finally {
svc.onModuleDestroy();
}
});
});
+180
View File
@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
// pure formatting helper (no cycle: battery → mesh only)
import { mention } from '../battery/battery.service.js';
import { BROADCAST_ADDR } from '../mesh/codec.js';
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
import type { MeshService } from '../mesh/mesh.service.js';
import type { RssService } from '../rss/rss.service.js';
/** Context handed to a command handler: the trimmed text plus sender/packet id when present. */
export interface CommandContext {
/** the trimmed rx text, e.g. `#ping` */
text: string;
/** sender node num — omitted for broadcasts/unknown senders */
from?: number;
/** packet id of the triggering message */
id?: number;
}
/** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */
export type CommandHandler = (
ctx: CommandContext,
) => string | string[] | undefined | Promise<string | string[] | undefined>;
@Injectable()
export class CommandService {
private readonly logger = new Logger(CommandService.name);
// read at construction, like other services' env config
private readonly commandChannel = parseInt(
process.env.COMMAND_CHANNEL ?? '2',
10,
);
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
private readonly multiSendDelayMs = parseInt(
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
10,
);
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
private readonly commands = new Map<string, CommandHandler>();
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
private readonly prefixes = new Map<string, CommandHandler>();
/** mesh is optional so tests can construct without a bridge; production DI always passes one. */
constructor(
private readonly mesh?: MeshService,
rss?: RssService,
) {
this.register('ping', () => '#pong!');
this.register('loser', () => this.loserReply());
// single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line
this.register('help', () =>
[
'#ping - alive check',
'#loser - worst delivery record',
'#rss help - rss subcommands',
'#rss add <url> <name> - add feed with AI tags',
'#rss remove <name> - drop feed',
'#rss get <name|#tag> - news summary',
'#rss sources / #rss tags - list feeds/tags',
].join('\n'),
);
if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx));
}
/**
* #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide
* loss stats, so the only real data is our own tx history: group by destination, count delivered vs
* failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and
* the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most
* failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars.
*/
private async loserReply(): Promise<string> {
const mesh = this.mesh;
if (!mesh)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
const stats = new Map<number, { failed: number; delivered: number }>();
for (const m of mesh.messages()) {
if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR)
continue; // directed sends only
if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet
const s = stats.get(m.to) ?? { failed: 0, delivered: 0 };
if (m.status === 'failed') s.failed += 1;
else s.delivered += 1;
stats.set(m.to, s);
}
if (stats.size === 0)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
let worst: { num: number; failed: number; total: number } | undefined;
for (const [num, s] of stats) {
if (s.failed === 0) continue; // zero-failure nodes can't be the loser
const total = s.failed + s.delivered;
// most failures first, then worse failure ratio on a tie
if (
!worst ||
s.failed > worst.failed ||
(s.failed === worst.failed &&
s.failed / total > worst.failed / worst.total)
) {
worst = { num, failed: s.failed, total };
}
}
if (!worst) return 'no failed sends yet - everyone delivers :)';
const names = new Map(
(await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]),
);
const name = names.get(worst.num);
const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num);
return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`;
}
/** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
register(name: string, fn: CommandHandler): void {
this.commands.set(name, fn);
}
/** Register a prefix handler: owns bare `#<name>` and everything starting with `#<name> `. Exact matches win over prefixes. */
registerPrefix(name: string, fn: CommandHandler): void {
this.prefixes.set(name, fn);
}
/**
* Event-driven entry point called by MeshService for every decoded rx text.
* No-op unless on COMMAND_CHANNEL with a case-sensitive `#command` match — exact name or registered prefix family (`#rss ...`).
* Replies go through MeshService.respond(id, reply): auto-routed to the original message's
* destination and channel as a threaded quote (replyId = id). Only when the trigger has no packet
* id does it fall back to legacy sendText routing on COMMAND_CHANNEL. Never throws — a failed
* command must not crash the app.
*/
async onRx(
channel: number,
text: string,
from?: number,
id?: number,
): Promise<void> {
if (channel !== this.commandChannel) return;
const trimmed = text.trim();
if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers
const name = trimmed.slice(1);
let handler = this.commands.get(name);
if (!handler) {
for (const [prefix, fn] of this.prefixes) {
// a prefix owns bare `#<prefix>` and everything starting with `#<prefix> ` — e.g. 'rss' → #rss / #rss add ...
if (name === prefix || name.startsWith(prefix + ' ')) {
handler = fn;
break;
}
}
}
if (!handler) return; // exact, case-sensitive match — no partials
try {
const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply
// an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
const lines = Array.isArray(reply) ? reply : [reply];
for (let i = 0; i < lines.length; i++) {
if (i > 0 && this.multiSendDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, this.multiSendDelayMs),
);
}
const line = lines[i];
if (id !== undefined && this.mesh) {
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, line);
} else {
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
await this.mesh?.sendText(
from ?? BROADCAST_ADDR,
line,
undefined,
this.commandChannel,
);
}
}
} catch (err) {
this.logger.warn(
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { MeshService } from '../mesh/mesh.service.js';
import { CommandModule } from './command.module.js';
import { CommandService } from './command.service.js';
describe('DI wiring (module graph)', () => {
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
it('resolves CommandService with MeshService provided by MeshModule', async () => {
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
const moduleRef = await Test.createTestingModule({
imports: [CommandModule],
}).compile();
expect(moduleRef.get(CommandService)).toBeInstanceOf(CommandService);
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
});
// the MeshService <-> CommandService cycle must resolve to live references on both sides, not undefined
it('wires both sides of the circular dependency with real instances', async () => {
process.env.MESH_DB_PATH = ':memory:';
const moduleRef = await Test.createTestingModule({
imports: [CommandModule],
}).compile();
const mesh = moduleRef.get(MeshService);
const commands = moduleRef.get(CommandService);
expect((commands as unknown as { mesh?: MeshService }).mesh).toBe(mesh);
expect((mesh as unknown as { commands?: CommandService }).commands).toBe(
commands,
);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { LlmService } from './llm.service.js';
@Module({
providers: [LlmService],
exports: [LlmService],
})
export class LlmModule {}
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatMessage } from './llm.service.js';
import { LlmService } from './llm.service.js';
const BASE = 'http://192.168.1.70:1111/v1';
function json(body: unknown): Response {
return { ok: true, status: 200, json: async () => body } as Response;
}
/** Fresh service with a clean env (explicit base/model/timeout). */
function makeService(): LlmService {
process.env.LLM_API_BASE = BASE;
process.env.LLM_MODEL = 'qwen3.8-27b';
process.env.LLM_TIMEOUT_MS = '60000';
return new LlmService();
}
describe('LlmService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
for (const k of ['LLM_API_BASE', 'LLM_MODEL', 'LLM_TIMEOUT_MS'])
delete process.env[k];
});
it('listModels returns the data[].id strings from GET /models', async () => {
const svc = makeService();
fetchMock.mockResolvedValueOnce(
json({
data: [
{ id: 'qwen3.8-27b' },
{ id: 'text-embedding-nomic-embed-text-v1.5' },
],
}),
);
await expect(svc.listModels()).resolves.toEqual([
'qwen3.8-27b',
'text-embedding-nomic-embed-text-v1.5',
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/models`);
});
it('chat posts model+messages and returns choices[0].message.content', async () => {
const svc = makeService();
fetchMock.mockResolvedValueOnce(
json({ choices: [{ message: { content: 'hello back' } }] }),
);
const messages: ChatMessage[] = [
{ role: 'system', content: 'be brief' },
{ role: 'user', content: 'hi' },
];
await expect(
svc.chat(messages, { maxTokens: 50, temperature: 0.2 }),
).resolves.toBe('hello back');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${BASE}/chat/completions`);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
model: 'qwen3.8-27b',
messages,
maxTokens: 50,
temperature: 0.2,
});
});
it('chat throws on non-2xx with the HTTP status in the message', async () => {
const svc = makeService();
fetchMock.mockResolvedValueOnce({
ok: false,
status: 503,
text: async () => 'upstream down',
} as Response);
await expect(svc.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
/HTTP 503.*upstream down/,
);
});
it('summarize calls chat with the system prompt plus the user text', async () => {
const svc = makeService();
fetchMock.mockResolvedValueOnce(
json({ choices: [{ message: { content: 'the summary' } }] }),
);
await expect(svc.summarize('a long text')).resolves.toBe('the summary');
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init.body).messages).toEqual([
{ role: 'system', content: 'Summarize the following text concisely.' },
{ role: 'user', content: 'a long text' },
]);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
/** A single chat message in OpenAI chat-completions shape. */
export interface ChatMessage {
role: string;
content: string;
}
@Injectable()
export class LlmService {
// read at construction, like other services' env config
private readonly apiBase =
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
// local 27B model can be slow (uncontrollable reasoning tokens) — generous default: 3 min headroom for slow reasoning periods
private readonly timeoutMs = parseInt(
process.env.LLM_TIMEOUT_MS ?? '180000',
10,
);
/** GET {base}/models → the `data[].id` strings. */
async listModels(): Promise<string[]> {
const res = await fetch(`${this.apiBase}/models`, {
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!res.ok) {
throw new Error(
`listModels failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
);
}
const body = (await res.json()) as { data?: { id: string }[] };
return (body.data ?? []).map((m) => m.id);
}
/** POST {base}/chat/completions → `choices[0].message.content`. */
async chat(
messages: ChatMessage[],
opts?: { maxTokens?: number; temperature?: number },
): Promise<string> {
const res = await fetch(`${this.apiBase}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: this.model, messages, ...opts }),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!res.ok) {
throw new Error(
`chat failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
);
}
const body = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('chat failed: no choices[0].message.content in response');
}
return content;
}
/** Thin wrapper over chat(): the stated primary use of this service. */
async summarize(text: string): Promise<string> {
return this.chat([
{ role: 'system', content: 'Summarize the following text concisely.' },
{ role: 'user', content: text },
]);
}
}
-165
View File
@@ -1,165 +0,0 @@
import { create, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
onSocket?: (s: net.Socket) => void,
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
export async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
export interface NodeInfoFields {
user?: {
id?: string;
longName?: string;
shortName?: string;
hwModel?: number;
};
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
snr?: number;
viaMqtt?: boolean;
hopsAway?: number;
}
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
type Init<T> = {
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
};
/** DeviceMetadata frame as streamed once during the initial sync. */
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
const fr = create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'metadata', value: md },
});
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
}
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
export function configSectionFrame(
sectionCase: P.Config.Config['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const cfg = create(P.Config.ConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.Config.Config['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'config', value: cfg },
}),
),
);
}
export function moduleConfigSectionFrame(
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
value: unknown,
): Uint8Array {
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: sectionCase,
value,
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'moduleConfig', value: mc },
}),
),
);
}
export function nodeInfoFrame(
num: number,
fields?: NodeInfoFields,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
/** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */
export function telemetryPacketFrame(
from: number,
variant: P.Telemetry.Telemetry['variant'],
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TELEMETRY_APP,
payload: toBinary(
P.Telemetry.TelemetrySchema,
create(P.Telemetry.TelemetrySchema, { variant }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
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 {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
+158 -60
View File
@@ -1,18 +1,12 @@
import { fromBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import type * as net from 'node:net';
import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames } from './codec.js';
import { BROADCAST_ADDR } from './codec.js';
import { FakeMeshMonitorClient } from './meshmonitor/fake-mesh-monitor-client.js';
import {
KAMIL_NUM,
kamilNodeInfo,
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
waitFor,
} from './fake-bridge.js';
nodeNumToId,
type MMMessage,
} from './meshmonitor/mesh-monitor-client.js';
import { createMeshMcpServer } from './mcp.controller.js';
import { MeshService } from './mesh.service.js';
@@ -25,23 +19,63 @@ function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
return c.text ?? '';
}
/** The bot's own mesh node number (the gateway's view of "us"). */
const MY_NUM = 3994646728; // 0xee1978c8
/** A regular peer node. */
const KAMIL_NUM = 123456789;
const KAMIL_ID = nodeNumToId(KAMIL_NUM);
/** A fixed "past" timestamp (2025-10-09) for deterministic ordering. */
const T0 = 1_760_000_000_000;
function kamilNode() {
return { num: KAMIL_NUM, longName: 'Kamil SP8MK/P', shortName: 'KK' };
}
/** An inbound (mesh -> bot) text message as the gateway reports it (channel 3, addressed to us). */
function inbound(over: Partial<MMMessage> & { text: string }): MMMessage {
return {
packetId: 1,
fromNum: KAMIL_NUM,
toNum: MY_NUM,
channel: 3,
portnum: 1,
at: T0,
...over,
};
}
function makeClient(): FakeMeshMonitorClient {
const client = new FakeMeshMonitorClient({ localNum: MY_NUM });
client.setStatus({
connected: true,
myNodeNum: MY_NUM,
longName: 'SP8MK Bot',
nodeResponsive: true,
});
return client;
}
/**
* A fully synced service on a fake gateway client (deterministic: the status
* poll drives the one-shot initial sync; tests drive the other polls explicitly).
*/
async function connectedService(): Promise<{
svc: MeshService;
sockets: net.Socket[];
client: FakeMeshMonitorClient;
close: () => void;
}> {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
// pin the gateway base so the status() host/port assertion is deterministic
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
const client = makeClient();
const svc = new MeshService(undefined, undefined, client);
svc.onModuleInit();
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
await svc.pollStatus();
return {
svc,
sockets: bridge.sockets,
client,
close: () => {
svc.onModuleDestroy();
bridge.close();
delete process.env.MESHMONITOR_API_BASE;
},
};
}
@@ -67,7 +101,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 +116,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);
@@ -100,6 +135,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();
}
@@ -108,7 +150,7 @@ describe('MCP server tools', () => {
}
});
it('mesh_status returns the bridge connection state', async () => {
it('mesh_status returns the gateway connection state', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
@@ -120,9 +162,9 @@ describe('MCP server tools', () => {
expect(r.isError).not.toBe(true);
expect(JSON.parse(toolText(r))).toEqual({
connected: true,
myNodeNum: null,
host: '127.0.0.1',
port: Number(process.env.MESH_PORT),
myNodeNum: MY_NUM,
host: 'meshmonitor.kkhost.pl',
port: 443,
});
} finally {
await mcp.close();
@@ -135,8 +177,8 @@ describe('MCP server tools', () => {
it('send_message broadcasts by default and resolves names to node numbers', async () => {
const env = await connectedService();
try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await env.svc.nodes()).length === 1);
env.client.setNodes([kamilNode()]);
await env.svc.pollNodes();
const mcp = await connectMcp(env.svc);
try {
@@ -151,6 +193,7 @@ describe('MCP server tools', () => {
dir: 'tx',
to: BROADCAST_ADDR,
});
expect(env.client.sends[0]?.channel).toBe(3); // pinned test channel
// name lookup via the node DB (unique substring)
const r2 = await mcp.client.callTool({
@@ -162,6 +205,7 @@ describe('MCP server tools', () => {
dir: 'tx',
to: KAMIL_NUM,
});
expect(env.client.sends[1]?.toNodeId).toBe(KAMIL_ID); // a gateway DM
} finally {
await mcp.close();
}
@@ -196,16 +240,69 @@ describe('MCP server tools', () => {
}
});
it('inject_message records an rx entry without sending over RF', async () => {
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.client.pushInbound(
inbound({ text: 'ping', packetId: 1, channel: 3 }),
);
await env.svc.pollMessages();
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 — in mesh terms DMs ride the primary
// channel (recorded as 0) and address the node by id
expect(env.svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: 'pong',
channel: 0,
});
expect(env.client.sends[0]).toMatchObject({
toNodeId: KAMIL_ID,
replyId: 1,
});
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 the air', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
try {
// capture outbound bytes: the only frame ever written on connect is want_config,
// so any 'packet' variant after this point would be an RF transmission
const out: Uint8Array[] = [];
env.sockets[0].on('data', (c) => out.push(c));
const r1 = await mcp.client.callTool({
name: 'inject_message',
arguments: { text: 'trigger' },
@@ -237,19 +334,8 @@ describe('MCP server tools', () => {
expect(r3.isError).toBe(true);
expect(toolText(r3)).toContain('invalid node number');
// nothing went over the wire — synthetic inbox injection only.
// The want_config frame (written on connect) may or may not have been captured;
// what matters is that no ToRadio 'packet' variant ever made it to the bridge.
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;
}
for (const p of extractFrames(merged).packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
expect(tr.payloadVariant?.case).not.toBe('packet');
}
// nothing was transmitted — synthetic inbox injection only
expect(env.client.sends).toHaveLength(0);
} finally {
await mcp.close();
}
@@ -261,8 +347,8 @@ describe('MCP server tools', () => {
it('get_node resolves by name and errors when missing', async () => {
const env = await connectedService();
try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await env.svc.nodes()).length === 1);
env.client.setNodes([kamilNode()]);
await env.svc.pollNodes();
const mcp = await connectMcp(env.svc);
try {
@@ -294,8 +380,19 @@ describe('MCP server tools', () => {
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
const env = await connectedService();
try {
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await env.svc.nodes()).length === 1);
env.client.setChannels([{ index: 3, name: 'test' }]);
env.client.setNodes([kamilNode()]);
env.client.setTelemetry([
{
fromNum: KAMIL_NUM,
at: T0,
kind: 'batteryLevel',
data: { value: 87, packetId: 77 },
},
]);
await env.svc.pollChannels();
await env.svc.pollNodes();
await env.svc.pollTelemetry();
const mcp = await connectMcp(env.svc);
try {
@@ -308,16 +405,24 @@ describe('MCP server tools', () => {
}),
),
),
).toEqual([]); // no channel frames fed yet
).toEqual([
{
index: 3,
name: 'test',
uplinkEnabled: false,
downlinkEnabled: false,
},
]);
// no metadata/config frames fed yet → empty sections, null node num
// degraded device info: identity from the gateway, empty config sections
// (the gateway has no device-metadata/config endpoints)
expect(
JSON.parse(
toolText(
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
),
),
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
).toEqual({ myNodeNum: MY_NUM, localConfig: {}, moduleConfig: {} });
const nodes = JSON.parse(
toolText(
@@ -339,13 +444,6 @@ describe('MCP server tools', () => {
) as { dir: string; to?: number }[];
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
env.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87 },
}),
);
await waitFor(() => env.svc.telemetry().length === 1);
const tel = JSON.parse(
toolText(
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
@@ -356,7 +454,7 @@ describe('MCP server tools', () => {
from: KAMIL_NUM,
kind: 'deviceMetrics',
});
expect(tel[0].data).toEqual({ batteryLevel: 87 });
expect(tel[0].data).toEqual({ batteryLevel: { value: 87 } });
} finally {
await mcp.close();
}
+13
View File
@@ -46,6 +46,19 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
}),
);
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(
'list_channels',
{
+59 -32
View File
@@ -29,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;
@@ -58,7 +69,7 @@ export class MeshController {
@ApiOperation({
summary: 'Bridge connection status',
description:
'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.',
'Whether the device behind the MeshMonitor gateway is connected and which node number this device has; host/port identify the gateway.',
})
@ApiResponse({
status: 200,
@@ -66,8 +77,8 @@ export class MeshController {
example: {
connected: true,
myNodeNum: 123456789,
host: '192.168.1.4',
port: 4403,
host: 'meshmonitor.kkhost.pl',
port: 443,
},
})
status() {
@@ -111,6 +122,24 @@ export class MeshController {
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')
@ApiOperation({
summary: "The device's Meshtastic channels (keys redacted)",
@@ -136,9 +165,9 @@ export class MeshController {
@Get('device')
@ApiOperation({
summary: 'Device info (metadata + config, keys redacted)',
summary: 'Device info (firmware; config sections degraded)',
description:
'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.',
"This device's node number and the firmware version the gateway reports for it. Degraded versus the old TCP bridge: the gateway has no device-metadata or config endpoints, so localConfig/moduleConfig are always empty (key material is never exposed). In-memory only.",
})
@ApiResponse({
status: 200,
@@ -146,33 +175,8 @@ export class MeshController {
example: {
myNodeNum: 123456789,
firmwareVersion: '2.7.0.1',
metadata: {
firmwareVersion: '2.7.0.1',
deviceStateVersion: 1,
canShutdown: true,
hasWifi: false,
hasBluetooth: true,
hasEthernet: false,
role: 3,
positionFlags: 65,
hwModel: 42,
hasRemoteHardware: false,
hasPKC: true,
excludedModules: 0,
},
localConfig: {
device: { role: 3, buttonGpio: 13 },
security: {
publicKey: 'REDACTED',
privateKey: 'REDACTED',
adminKey: 'REDACTED',
isManaged: true,
},
network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' },
},
moduleConfig: {
telemetry: { environmentUpdateInterval: 900 },
},
localConfig: {},
moduleConfig: {},
},
})
device(): DeviceInfo {
@@ -212,6 +216,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 in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
})
@ApiResponse({
status: 200,
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',
+14 -1
View File
@@ -2,8 +2,14 @@ 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 {
MESH_MONITOR_CLIENT,
MeshMonitorModule,
} from './meshmonitor/meshmonitor.module.js';
import type { MeshMonitorClient } from './meshmonitor/mesh-monitor-client.js';
import { initMessagesTable, MeshService } from './mesh.service.js';
// SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing.
@@ -16,11 +22,18 @@ function openMessageDb(): DatabaseSync {
}
@Module({
imports: [MeshMonitorModule],
controllers: [MeshController, McpController],
providers: [
OpencodeService,
{
// the MeshMonitor client is null when MESHMONITOR_API_TOKEN is unset — the service then stays disconnected
provide: MeshService,
useFactory: () => new MeshService(openMessageDb()),
useFactory: (
opencode: OpencodeService,
client: MeshMonitorClient | null,
) => new MeshService(openMessageDb(), opencode, client),
inject: [OpencodeService, MESH_MONITOR_CLIENT],
},
],
exports: [MeshService],
+1571 -1279
View File
File diff suppressed because it is too large Load Diff
+584 -381
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FakeMeshMonitorClient } from './fake-mesh-monitor-client.js';
import { BROADCAST_ADDR } from './mesh-monitor-client.js';
const BOT_NUM = 3994646728; // !ee1978c8
const KAMIL_NUM = 2927200947; // !ae798ab3
describe('FakeMeshMonitorClient', () => {
let fake: FakeMeshMonitorClient;
beforeEach(() => {
fake = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
});
// ------------------------------------------------------------------
// status
// ------------------------------------------------------------------
describe('getStatus', () => {
it('returns a default status until configured', async () => {
expect(await new FakeMeshMonitorClient().getStatus()).toEqual({
connected: true,
myNodeNum: 0,
});
});
it('returns the configured status (defensively copied)', async () => {
fake.setStatus({ connected: false, myNodeNum: BOT_NUM, longName: 'Bot' });
const s = await fake.getStatus();
s.connected = true; // mutate the returned copy
expect((await fake.getStatus()).connected).toBe(false);
});
});
// ------------------------------------------------------------------
// nodes
// ------------------------------------------------------------------
describe('getNodes / getNode', () => {
beforeEach(() => {
fake.setNodes([
{
num: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
position: { lat: 51, lon: 22 },
},
{ num: KAMIL_NUM, longName: 'Kamil', shortName: 'KK' },
]);
});
it('returns all configured nodes', async () => {
expect(await fake.getNodes()).toHaveLength(2);
});
it('finds a node by number', async () => {
expect((await fake.getNode(KAMIL_NUM))?.longName).toBe('Kamil');
});
it('finds a node by !hex id', async () => {
expect((await fake.getNode('!ae798ab3'))?.longName).toBe('Kamil');
expect((await fake.getNode('!ee1978c8'))?.longName).toBe('SP8MK Bot');
});
it('finds a node by numeric string', async () => {
expect((await fake.getNode(String(KAMIL_NUM)))?.longName).toBe('Kamil');
});
it('finds a node by name (case-insensitive)', async () => {
expect((await fake.getNode('kamil'))?.num).toBe(KAMIL_NUM);
expect((await fake.getNode('8mkB'))?.num).toBe(BOT_NUM);
});
it('returns null for an unknown node', async () => {
expect(await fake.getNode(999)).toBeNull();
expect(await fake.getNode('!deadbeef')).toBeNull();
expect(await fake.getNode('nobody')).toBeNull();
});
});
describe('constructor + pushNode', () => {
it('seeds the local node and reports the source identity', async () => {
const f = new FakeMeshMonitorClient({
nodeNum: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
connected: false,
});
expect(await f.getStatus()).toEqual({
connected: false,
myNodeNum: BOT_NUM,
longName: 'SP8MK Bot',
});
const nodes = await f.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'SP8MK Bot',
shortName: '8MKb',
lastHeard: 0,
});
});
it('pushNode upserts by num — replace in place when present, append when new', async () => {
const f = new FakeMeshMonitorClient({ nodeNum: BOT_NUM });
f.pushNode({ num: BOT_NUM, longName: 'renamed', lastHeard: 5 });
f.pushNode({ num: KAMIL_NUM, longName: 'Kamil' });
const nodes = await f.getNodes();
// order preserved: the seeded bot node first (replaced in place), Kamil appended
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
num: BOT_NUM,
longName: 'renamed',
lastHeard: 5,
});
expect(nodes[1]).toMatchObject({ num: KAMIL_NUM, longName: 'Kamil' });
});
it('a zero/omitted nodeNum seeds no node and reports myNodeNum 0', async () => {
expect(await new FakeMeshMonitorClient().getNodes()).toEqual([]);
});
});
// ------------------------------------------------------------------
// channels + telemetry (passthrough of configured data)
// ------------------------------------------------------------------
describe('getChannels / getTelemetry', () => {
it('returns configured channels and telemetry', async () => {
fake.setChannels([
{ index: 0, name: '', psk: 'REDACTED' },
{ index: 3, name: 'test' },
]);
fake.setTelemetry([
{ fromNum: BOT_NUM, at: 1, kind: 'airUtilTx', data: { value: 0.1 } },
]);
expect(await fake.getChannels()).toHaveLength(2);
expect((await fake.getTelemetry())[0].kind).toBe('airUtilTx');
});
it('returns empty arrays by default', async () => {
expect(await fake.getChannels()).toEqual([]);
expect(await fake.getTelemetry()).toEqual([]);
});
});
// ------------------------------------------------------------------
// pushInbound + getMessages
// ------------------------------------------------------------------
describe('getMessages', () => {
it('returns inbound messages newest first', async () => {
fake.pushInbound({ packetId: 1, text: 'first', channel: 0, at: 100 });
fake.pushInbound({ packetId: 2, text: 'second', channel: 0, at: 200 });
const msgs = await fake.getMessages();
expect(msgs.map((m) => m.text)).toEqual(['second', 'first']);
});
it('filters by channel', async () => {
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
fake.pushInbound({ packetId: 2, text: 'b', channel: 3, at: 2 });
const msgs = await fake.getMessages({ channel: 3 });
expect(msgs.map((m) => m.text)).toEqual(['b']);
});
it('filters by toNodeId and fromNodeId', async () => {
fake.pushInbound({
packetId: 1,
fromId: '!ee1978c8',
toId: '!ffffffff',
text: 'bcast',
channel: 3,
at: 1,
});
fake.pushInbound({
packetId: 2,
fromId: '!ae798ab3',
toId: '!ee1978c8',
text: 'dm',
channel: -1,
at: 2,
});
expect(
(await fake.getMessages({ toNodeId: '!ffffffff' })).map((m) => m.text),
).toEqual(['bcast']);
expect(
(await fake.getMessages({ fromNodeId: '!ae798ab3' })).map(
(m) => m.text,
),
).toEqual(['dm']);
});
it('applies limit (newest N) after filtering', async () => {
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
fake.pushInbound({ packetId: 2, text: 'b', channel: 0, at: 2 });
fake.pushInbound({ packetId: 3, text: 'c', channel: 3, at: 3 });
// newest-first: c, b, a — limit 2 → c, b
expect((await fake.getMessages({ limit: 2 })).map((m) => m.text)).toEqual(
['c', 'b'],
);
// limit + channel: only c is on ch3
expect(
(await fake.getMessages({ limit: 2, channel: 3 })).map((m) => m.text),
).toEqual(['c']);
});
it('returns a defensive copy (mutating the result does not leak)', async () => {
fake.pushInbound({ packetId: 1, text: 'x', channel: 0, at: 1 });
const m = (await fake.getMessages())[0];
m.text = 'mutated';
expect((await fake.getMessages())[0].text).toBe('x');
});
});
// ------------------------------------------------------------------
// send
// ------------------------------------------------------------------
describe('send', () => {
it('records a directed send and mirrors it as an outbound message', async () => {
const out = await fake.send({ text: 'hi kamil', toNodeId: '!ae798ab3' });
expect(out.deliveryState).toBe('pending');
expect(out.packetId).toBeGreaterThan(0);
expect(fake.sends).toHaveLength(1);
expect(fake.sends[0]).toMatchObject({
text: 'hi kamil',
toNodeId: '!ae798ab3',
packetId: out.packetId,
});
const msgs = await fake.getMessages();
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatchObject({
packetId: out.packetId,
fromNum: BOT_NUM,
fromId: '!ee1978c8',
toNum: KAMIL_NUM,
toId: '!ae798ab3',
text: 'hi kamil',
channel: -1, // DM rides channel -1
requestId: out.packetId,
deliveryState: 'pending',
});
});
it('records a broadcast send with the broadcast destination', async () => {
const out = await fake.send({ text: 'hello mesh', channel: 3 });
const msgs = await fake.getMessages();
expect(msgs[0]).toMatchObject({
toNum: BROADCAST_ADDR,
toId: '!ffffffff',
channel: 3,
text: 'hello mesh',
});
expect(out.packetId).toBe(msgs[0].packetId);
});
it('assigns a distinct, non-zero packet id per send', async () => {
const a = await fake.send({ text: 'one', toNodeId: '!ae798ab3' });
const b = await fake.send({ text: 'two', channel: 0 });
expect(a.packetId).not.toBe(b.packetId);
expect(a.packetId).toBeGreaterThan(0);
expect(b.packetId).toBeGreaterThan(0);
});
it('carries replyId onto the recorded message', async () => {
await fake.send({ text: 'replying', toNodeId: '!ae798ab3', replyId: 55 });
expect((await fake.getMessages())[0].replyId).toBe(55);
expect(fake.sends[0].replyId).toBe(55);
});
it('rejects XOR violations exactly like the HTTP client', async () => {
await expect(
fake.send({ text: 'x', channel: 3, toNodeId: '!1' }),
).rejects.toThrow(/mutually exclusive/);
await expect(fake.send({ text: 'x' })).rejects.toThrow(
/required \(XOR\)/,
);
await expect(fake.send({ text: '', channel: 3 })).rejects.toThrow(
/non-empty/,
);
// none of the rejected sends were recorded
expect(fake.sends).toHaveLength(0);
expect(await fake.getMessages()).toHaveLength(0);
});
it('clearTraffic() resets sends + messages + the packet-id counter', async () => {
await fake.send({ text: 'a', channel: 0 });
fake.pushInbound({ packetId: 1, text: 'in', channel: 0, at: 1 });
fake.clearTraffic();
expect(fake.sends).toHaveLength(0);
expect(await fake.getMessages()).toHaveLength(0);
// counter reset → first id is 1 again
expect((await fake.send({ text: 'b', channel: 0 })).packetId).toBe(1);
});
});
});
@@ -0,0 +1,250 @@
/**
* In-memory stand-in for the {@link MeshMonitorClient} — the Phase-2 test
* driver that lets specs exercise `MeshService` against the HTTP-client
* contract without touching the network:
* - construct it with the source identity the gateway would report
* (`{ connected, nodeNum, longName, shortName }`); the bot's own node is
* seeded in the node list automatically (the gateway always reports the
* local node)
* - set the world with the `set*`/`pushNode`/`pushInbound` helpers below
* - every `send()` is recorded in {@link FakeMeshMonitorClient.sends} AND
* appears as an outbound entry in `getMessages()` (newest first), exactly
* like the real gateway mirrors outbound packets
*
* It implements the exact {@link MeshMonitorClient} interface, so it is a
* drop-in replacement for the real client in the service's constructor.
*/
import {
BROADCAST_ADDR,
assertSendTarget,
nodeIdToNum,
nodeNumToId,
type MMChannel,
type MMMessage,
type MMNode,
type MMStatus,
type MMTelemetry,
type MeshMonitorClient,
} from './mesh-monitor-client.js';
/** One recorded {@link FakeMeshMonitorClient.send} call (input + assigned ids). */
export interface FakeSendRecord {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
/** Non-zero uint32 assigned by the fake (monotonic per instance). */
packetId: number;
deliveryState: string;
messageId?: string;
}
/**
* A send as it would be stored by the gateway: the fake mirrors what the real
* `POST /sources/{id}/messages` returns and what a later `GET .../messages`
* would surface for an outbound packet.
*/
export interface FakeMessageInput {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}
export class FakeMeshMonitorClient implements MeshMonitorClient {
private status: MMStatus;
private nodes: MMNode[] = [];
private channels: MMChannel[] = [];
private telemetry: MMTelemetry[] = [];
/** Inbound + outbound messages, newest first (matches the gateway order). */
private messages: MMMessage[] = [];
/** The bot's own node number — used as the sender of outbound messages. */
private readonly localNum: number;
private packetSeq = 0;
/** Every `send()` in call order. */
readonly sends: FakeSendRecord[] = [];
constructor(opts?: {
connected?: boolean;
nodeNum?: number;
longName?: string;
shortName?: string;
}) {
this.localNum = opts?.nodeNum ?? 0;
this.status = {
connected: opts?.connected ?? true,
myNodeNum: this.localNum,
};
if (opts?.longName) this.status.longName = opts.longName;
// the gateway always reports the local node in the node list
if (this.localNum !== 0)
this.pushNode({
num: this.localNum,
longName: opts?.longName,
shortName: opts?.shortName,
lastHeard: 0,
});
}
// ------------------------------------------------------------------
// Test-side configuration
// ------------------------------------------------------------------
setStatus(s: MMStatus): void {
this.status = { ...s };
}
setNodes(n: MMNode[]): void {
this.nodes = n.map((x) => ({ ...x }));
}
setChannels(c: MMChannel[]): void {
this.channels = c.map((x) => ({ ...x }));
}
setTelemetry(t: MMTelemetry[]): void {
this.telemetry = t.map((x) => ({ ...x }));
}
/** Upsert a node by `num` — replace in place when present, append when new. */
pushNode(n: MMNode): void {
const i = this.nodes.findIndex((x) => x.num === n.num);
if (i === -1) this.nodes.push({ ...n });
else this.nodes[i] = { ...n };
}
/**
* Record a message as if it arrived on the mesh (inbound). Prepend, so the
* newest stays first, exactly like the gateway's newest-first ordering.
*/
pushInbound(m: MMMessage): void {
this.messages.unshift({ ...m });
}
/** Reset sends + messages + the packet-id counter (keeps the configured world). */
clearTraffic(): void {
this.sends.length = 0;
this.messages.length = 0;
this.packetSeq = 0;
}
/**
* Mutate the delivery state of a stored message by packet id — models the
* gateway updating an outbound entry from `pending` to a final state in
* place (the same `id` reappears in later `GET .../messages` windows).
* No-op when no message has the id.
*/
setDeliveryState(packetId: number, state: MMMessage['deliveryState']): void {
for (const m of this.messages)
if (m.packetId === packetId) m.deliveryState = state;
}
// ------------------------------------------------------------------
// MeshMonitorClient
// ------------------------------------------------------------------
async getStatus(): Promise<MMStatus> {
return { ...this.status };
}
async getNodes(): Promise<MMNode[]> {
return this.nodes.map((n) => ({ ...n }));
}
async getNode(nodeIdOrNum: string | number): Promise<MMNode | null> {
const hit = this.nodes.find((n) => this.nodeMatches(n, nodeIdOrNum));
return hit ? { ...hit } : null;
}
/** Match a node by `!hex` id, node number (or numeric string), or exact name. */
private nodeMatches(n: MMNode, idOrNum: string | number): boolean {
if (typeof idOrNum === 'number') return n.num === idOrNum >>> 0;
const s = String(idOrNum).trim();
// `!hex` id
const asNum = nodeIdToNum(s);
if (asNum != null) return n.num === asNum;
// plain numeric string
if (/^[0-9]+$/.test(s)) return n.num === Number(s);
// name (case-insensitive, exact long/short — like the gateway lookup)
const lower = s.toLowerCase();
return (
n.longName?.toLowerCase() === lower ||
n.shortName?.toLowerCase() === lower
);
}
async getChannels(): Promise<MMChannel[]> {
return this.channels.map((c) => ({ ...c }));
}
async getTelemetry(): Promise<MMTelemetry[]> {
return this.telemetry.map((t) => ({ ...t }));
}
async getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]> {
let list = this.messages;
if (opts?.channel != null)
list = list.filter((m) => m.channel === opts.channel);
if (opts?.toNodeId != null)
list = list.filter((m) => m.toId === opts.toNodeId);
if (opts?.fromNodeId != null)
list = list.filter((m) => m.fromId === opts.fromNodeId);
if (opts?.limit != null) list = list.slice(0, opts.limit);
return list.map((m) => ({ ...m }));
}
async send(msg: FakeMessageInput): Promise<{
packetId: number;
deliveryState: string;
messageId?: string;
}> {
// identical validation to the HTTP client (shared assertSendTarget)
assertSendTarget(msg);
this.packetSeq = (this.packetSeq + 1) >>> 0;
if (this.packetSeq === 0) this.packetSeq = 1; // keep it a non-zero uint32
const packetId = this.packetSeq;
const deliveryState = 'pending';
const messageId = `fake-${packetId}`;
this.sends.push({
text: msg.text,
toNodeId: msg.toNodeId,
channel: msg.channel,
replyId: msg.replyId,
packetId,
deliveryState,
messageId,
});
// Mirror the gateway: an outbound message is readable back via getMessages().
const toNum =
msg.toNodeId != null
? (nodeIdToNum(msg.toNodeId) ?? undefined)
: BROADCAST_ADDR;
const toId = msg.toNodeId ?? nodeNumToId(BROADCAST_ADDR);
this.messages.unshift({
packetId,
fromNum: this.localNum,
fromId: nodeNumToId(this.localNum),
toNum,
toId,
text: msg.text,
// a channel send is a broadcast (channel >= 0); a DM rides channel -1
channel: msg.channel ?? -1,
portnum: 1,
requestId: packetId,
replyId: msg.replyId,
at: Date.now(),
deliveryState,
});
return { packetId, deliveryState, messageId };
}
}
@@ -0,0 +1,777 @@
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
BROADCAST_ADDR,
HttpMeshMonitorClient,
MeshMonitorError,
createMeshMonitorClientFromEnv,
nodeIdToNum,
nodeNumToId,
} from './mesh-monitor-client.js';
/** Test-only bearer token — never the real one (which lives only in .env). */
const TOKEN = 'mm_v1_test_token_not_real';
const SOURCE = 'srctest';
/** The gateway serves under `/api/v1`; the client's baseUrl carries it. */
const BASE = '/api/v1';
interface RecordedRequest {
method: string;
url: string;
pathname: string;
query?: string;
headers: http.IncomingHttpHeaders;
body: string;
}
interface CannedResponse {
status?: number;
body: unknown;
}
type Routes = Record<string, CannedResponse>;
interface Gateway {
baseUrl: string;
requests: RecordedRequest[];
close: () => Promise<void>;
}
function splitQuery(url: string): [string, string | undefined] {
const i = url.indexOf('?');
return i === -1 ? [url, undefined] : [url.slice(0, i), url.slice(i + 1)];
}
/**
* In-process `node:http` stand-in for the MeshMonitor gateway. Routes are keyed
* by `METHOD pathname` (query stripped). Every request is recorded so tests can
* assert the exact path, headers and body the client emitted.
*/
function startGateway(routes: Routes): Promise<Gateway> {
const requests: RecordedRequest[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const url = req.url ?? '/';
const [pathname, query] = splitQuery(url);
requests.push({
method: req.method ?? 'GET',
url,
pathname,
query,
headers: req.headers,
body,
});
const route = routes[`${req.method} ${pathname}`];
const status = route ? (route.status ?? 200) : 404;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(
JSON.stringify(
route
? route.body
: { success: false, error: `no route ${req.method} ${pathname}` },
),
);
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({
baseUrl: `http://127.0.0.1:${port}${BASE}`,
requests,
close: () =>
new Promise<void>((r) => {
server.closeAllConnections?.();
server.close(() => r());
}),
});
});
});
}
/** Route key for a source-scoped path, e.g. route('GET', '/status'). */
const route = (method: string, path: string): string =>
`${method} ${BASE}/sources/${SOURCE}${path}`;
let gw: Gateway | null = null;
beforeEach(() => {
gw = null;
});
afterEach(async () => {
await gw?.close();
gw = null;
for (const k of [
'MESHMONITOR_API_TOKEN',
'MESHMONITOR_API_BASE',
'MESHMONITOR_SOURCE',
'MESHMONITOR_TIMEOUT_MS',
])
delete process.env[k];
});
function makeClient(baseUrl: string): HttpMeshMonitorClient {
return new HttpMeshMonitorClient({
baseUrl,
sourceId: SOURCE,
token: TOKEN,
timeoutMs: 5000,
});
}
// ---------------------------------------------------------------------------
// Helpers: node id <-> number, broadcast address
// ---------------------------------------------------------------------------
describe('nodeNumToId / nodeIdToNum', () => {
it('maps a uint32 node number to an 8-char !hex id', () => {
expect(nodeNumToId(3994646728)).toBe('!ee1978c8');
expect(nodeNumToId(2927200947)).toBe('!ae798ab3');
expect(nodeNumToId(0)).toBe('!00000000');
expect(nodeNumToId(4294967295)).toBe('!ffffffff');
});
it('parses an !hex id back to a number (round-trip)', () => {
expect(nodeIdToNum('!ee1978c8')).toBe(3994646728);
expect(nodeIdToNum('!AE798AB3')).toBe(2927200947); // case-insensitive
expect(nodeIdToNum('!ffffffff')).toBe(4294967295);
for (const n of [1, 255, 0xdeadbeef, 4294967295])
expect(nodeIdToNum(nodeNumToId(n))).toBe(n);
});
it('rejects malformed ids with null', () => {
expect(nodeIdToNum('ee1978c8')).toBeNull();
expect(nodeIdToNum('!zzzz')).toBeNull();
expect(nodeIdToNum('!1234567890')).toBeNull(); // > 8 hex digits
expect(nodeIdToNum('')).toBeNull();
});
it('exposes the broadcast address as 0xffffffff / !ffffffff', () => {
expect(BROADCAST_ADDR).toBe(4294967295);
expect(nodeNumToId(BROADCAST_ADDR)).toBe('!ffffffff');
});
});
// ---------------------------------------------------------------------------
// getStatus
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getStatus', () => {
it('GETs the source status, sends the Bearer token, maps raw → MMStatus', async () => {
gw = await startGateway({
[route('GET', '/status')]: {
body: {
success: true,
data: {
localNodeNum: 3994646728,
localNodeId: '!ee1978c8',
longName: 'SP8MK Bot',
shortName: '8MKb',
connected: true,
nodeResponsive: true,
},
},
},
});
const s = await makeClient(gw.baseUrl).getStatus();
expect(s).toEqual({
connected: true,
myNodeNum: 3994646728,
longName: 'SP8MK Bot',
nodeResponsive: true,
});
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/status`);
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
});
it('defaults connected=false / myNodeNum=0 when fields are absent', async () => {
gw = await startGateway({
[route('GET', '/status')]: { body: { success: true, data: {} } },
});
const s = await makeClient(gw.baseUrl).getStatus();
expect(s.connected).toBe(false);
expect(s.myNodeNum).toBe(0);
expect(s.longName).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// getNodes
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getNodes', () => {
it('GETs with active/sinceDays, maps flat position + second-based lastHeard', async () => {
gw = await startGateway({
[route('GET', '/nodes')]: {
body: {
success: true,
count: 2,
data: [
{
nodeNum: 3994646728,
nodeId: '!ee1978c8',
longName: 'SP8MK Bot',
shortName: '8MKb',
hwModel: 63,
batteryLevel: 37,
voltage: 3.607,
firmwareVersion: '2.7.15.567b8ea',
rssi: null,
snr: 0,
lastHeard: 1791834334,
latitude: 51.253837,
longitude: 22.528193,
altitude: null,
hopsAway: null,
viaMqtt: false,
},
{
nodeNum: 2927200947,
nodeId: '!ae798ab3',
longName: 'Lubmesh Node',
shortName: 'LM',
hwModel: 14,
batteryLevel: 88,
lastHeard: 0,
latitude: null,
longitude: null,
snr: -8.25,
rssi: -118,
viaMqtt: false,
},
],
},
},
});
const nodes = await makeClient(gw.baseUrl).getNodes();
expect(nodes).toEqual([
{
num: 3994646728,
longName: 'SP8MK Bot',
shortName: '8MKb',
hwModel: 63,
batteryLevel: 37,
voltage: 3.607,
firmwareVersion: '2.7.15.567b8ea',
snr: 0,
lastHeard: 1791834334,
position: { lat: 51.253837, lon: 22.528193 },
},
{
num: 2927200947,
longName: 'Lubmesh Node',
shortName: 'LM',
hwModel: 14,
batteryLevel: 88,
snr: -8.25,
rssi: -118,
},
]);
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/nodes`);
expect(req.query).toBe('active=true&sinceDays=7');
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
});
});
// ---------------------------------------------------------------------------
// getNode
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getNode', () => {
it('resolves a numeric argument to !hex and maps the node detail', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ee1978c8')]: {
body: {
success: true,
data: {
nodeNum: 3994646728,
nodeId: '!ee1978c8',
longName: 'SP8MK Bot',
batteryLevel: 37,
lastHeard: 1791834334,
latitude: 51.253837,
longitude: 22.528193,
altitude: null,
viaMqtt: false,
},
},
},
});
const n = await makeClient(gw.baseUrl).getNode(3994646728);
expect(n?.num).toBe(3994646728);
expect(n?.longName).toBe('SP8MK Bot');
expect(n?.position).toEqual({ lat: 51.253837, lon: 22.528193 });
expect(gw.requests[0].pathname).toBe(
`${BASE}/sources/${SOURCE}/nodes/!ee1978c8`,
);
});
it('passes an explicit !hex id through unchanged', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ae798ab3')]: {
body: {
success: true,
data: { nodeNum: 2927200947, longName: 'X' },
},
},
});
const n = await makeClient(gw.baseUrl).getNode('!ae798ab3');
expect(n?.num).toBe(2927200947);
expect(gw.requests[0].pathname).toBe(
`${BASE}/sources/${SOURCE}/nodes/!ae798ab3`,
);
});
it('returns null on a 404 (unknown node) instead of throwing', async () => {
gw = await startGateway({
[route('GET', '/nodes/!00000001')]: {
status: 404,
body: { success: false, error: 'node not found' },
},
});
await expect(makeClient(gw.baseUrl).getNode(1)).resolves.toBeNull();
});
it('re-throws non-404 errors (e.g. 500)', async () => {
gw = await startGateway({
[route('GET', '/nodes/!ee1978c8')]: {
status: 500,
body: { success: false, error: 'boom' },
},
});
await expect(
makeClient(gw.baseUrl).getNode(3994646728),
).rejects.toMatchObject({ name: 'MeshMonitorError', status: 500 });
});
});
// ---------------------------------------------------------------------------
// getChannels
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getChannels', () => {
it('sorts by index and redacts the psk (marker when set, omitted when null)', async () => {
gw = await startGateway({
[route('GET', '/channels')]: {
body: {
success: true,
count: 3,
data: [
{
id: 0,
name: '',
displayName: 'Primary',
uplinkEnabled: true,
downlinkEnabled: false,
pskSet: true,
psk: 'AQ==',
},
{
id: 2,
name: 'testowy',
displayName: 'testowy',
uplinkEnabled: false,
downlinkEnabled: false,
pskSet: false,
psk: null,
},
{
id: 1,
name: 'LubMesh',
displayName: 'LubMesh',
uplinkEnabled: true,
downlinkEnabled: false,
pskSet: true,
psk: 'LQ==',
},
],
},
},
});
const ch = await makeClient(gw.baseUrl).getChannels();
expect(ch.map((c) => c.index)).toEqual([0, 1, 2]);
expect(ch).toEqual([
{
index: 0,
name: '',
psk: 'REDACTED',
uplinkEnabled: true,
downlinkEnabled: false,
},
{
index: 1,
name: 'LubMesh',
psk: 'REDACTED',
uplinkEnabled: true,
downlinkEnabled: false,
},
{
index: 2,
name: 'testowy',
uplinkEnabled: false,
downlinkEnabled: false,
},
]);
// the raw base64 key must never leak into the mapped output
expect(JSON.stringify(ch)).not.toContain('AQ==');
expect(JSON.stringify(ch)).not.toContain('LQ==');
});
});
// ---------------------------------------------------------------------------
// getTelemetry
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getTelemetry', () => {
it('maps long-format rows to { fromNum, at(ms), kind, data }', async () => {
gw = await startGateway({
[route('GET', '/telemetry')]: {
body: {
success: true,
count: 3,
data: [
{
nodeNum: 3994646728,
telemetryType: 'airUtilTx',
timestamp: 1791834214540,
value: 0.1517,
unit: '%',
packetId: 3971503092,
channel: null,
gpsAccuracy: null,
rxSnr: null,
},
{
nodeNum: 4059237616,
telemetryType: 'altitude',
timestamp: 1791834193282,
value: 185,
unit: 'm',
packetId: 3637205748,
channel: 0,
gpsAccuracy: null,
rxSnr: null,
},
{
nodeNum: 1475656844,
telemetryType: 'linkQuality',
timestamp: 1791834161250,
value: 9,
unit: 'quality',
packetId: null,
channel: null,
gpsAccuracy: null,
rxSnr: null,
},
],
},
},
});
const t = await makeClient(gw.baseUrl).getTelemetry();
expect(t).toEqual([
{
fromNum: 3994646728,
at: 1791834214540,
kind: 'airUtilTx',
data: { value: 0.1517, unit: '%', packetId: 3971503092 },
},
{
fromNum: 4059237616,
at: 1791834193282,
kind: 'altitude',
data: { value: 185, unit: 'm', packetId: 3637205748, channel: 0 },
},
{
fromNum: 1475656844,
at: 1791834161250,
kind: 'linkQuality',
data: { value: 9, unit: 'quality' },
},
]);
});
});
// ---------------------------------------------------------------------------
// getMessages
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.getMessages', () => {
it('maps messages (packetId from the id string, ms timestamps), newest first', async () => {
gw = await startGateway({
[route('GET', '/messages')]: {
body: {
success: true,
count: 2,
data: [
{
id: 'src_3994646728_268035341',
fromNodeNum: 3994646728,
fromNodeId: '!ee1978c8',
toNodeNum: 4294967295,
toNodeId: 'broadcast',
text: 'hi ch3',
channel: 3,
portnum: 1,
requestId: 268035341,
timestamp: 1791832779000,
replyId: 1807244554,
rxSnr: null,
viaMqtt: null,
deliveryState: 'delivered',
},
{
id: 'src_2927200947_3264248582',
fromNodeNum: 2927200947,
fromNodeId: '!ae798ab3',
toNodeNum: 4294967295,
toNodeId: '!ffffffff',
text: 'weather',
channel: 1,
portnum: 1,
requestId: null,
timestamp: 1791828092137,
replyId: null,
rxSnr: -7.5,
viaMqtt: false,
deliveryState: null,
},
],
},
},
});
const msgs = await makeClient(gw.baseUrl).getMessages();
expect(msgs).toEqual([
{
packetId: 268035341,
fromNum: 3994646728,
fromId: '!ee1978c8',
toNum: 4294967295,
toId: 'broadcast',
text: 'hi ch3',
channel: 3,
portnum: 1,
requestId: 268035341,
replyId: 1807244554,
at: 1791832779000,
deliveryState: 'delivered',
},
{
packetId: 3264248582,
fromNum: 2927200947,
fromId: '!ae798ab3',
toNum: 4294967295,
toId: '!ffffffff',
text: 'weather',
channel: 1,
portnum: 1,
at: 1791828092137,
snr: -7.5,
},
]);
});
it('encodes the limit/channel/toNodeId/fromNodeId filters into the query', async () => {
gw = await startGateway({
[route('GET', '/messages')]: { body: { success: true, data: [] } },
});
await makeClient(gw.baseUrl).getMessages({
limit: 10,
channel: 3,
toNodeId: '!ffffffff',
fromNodeId: '!ae798ab3',
});
// URLSearchParams percent-encodes `!` as %21 — assert on the decoded values
const q = new URLSearchParams(gw.requests[0].query);
expect(q.get('limit')).toBe('10');
expect(q.get('channel')).toBe('3');
expect(q.get('toNodeId')).toBe('!ffffffff');
expect(q.get('fromNodeId')).toBe('!ae798ab3');
});
});
// ---------------------------------------------------------------------------
// send
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient.send', () => {
it('sends a DM: POSTs { text, toNodeId, replyId } and returns the packet id', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { messageId: 'mid-1', requestId: 42, deliveryState: 'pending' },
},
},
});
const out = await makeClient(gw.baseUrl).send({
text: 'hello',
toNodeId: '!ae798ab3',
replyId: 7,
});
expect(out).toEqual({
packetId: 42,
deliveryState: 'pending',
messageId: 'mid-1',
});
const req = gw.requests[0];
expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/messages`);
expect(req.headers['content-type']).toBe('application/json');
expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(JSON.parse(req.body)).toEqual({
text: 'hello',
toNodeId: '!ae798ab3',
replyId: 7,
});
});
it('sends a broadcast: POSTs { text, channel } (no toNodeId)', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { messageId: 'mid-2', requestId: 99, deliveryState: 'pending' },
},
},
});
const out = await makeClient(gw.baseUrl).send({
text: 'hi all',
channel: 3,
});
expect(out.packetId).toBe(99);
const body = JSON.parse(gw.requests[0].body);
expect(body).toEqual({ text: 'hi all', channel: 3 });
expect(body).not.toHaveProperty('toNodeId');
expect(body).not.toHaveProperty('replyId');
});
it('omits replyId when not provided', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: {
success: true,
data: { requestId: 5, deliveryState: 'pending' },
},
},
});
await makeClient(gw.baseUrl).send({ text: 'x', toNodeId: '!ee1978c8' });
const body = JSON.parse(gw.requests[0].body);
expect(body).toEqual({ text: 'x', toNodeId: '!ee1978c8' });
expect(body).not.toHaveProperty('replyId');
});
it('defaults deliveryState to pending when the response omits it', async () => {
gw = await startGateway({
[route('POST', '/messages')]: {
body: { success: true, data: { requestId: 3 } },
},
});
const out = await makeClient(gw.baseUrl).send({ text: 'y', channel: 0 });
expect(out.deliveryState).toBe('pending');
expect(out.packetId).toBe(3);
expect(out.messageId).toBeUndefined();
});
it('rejects a send that sets BOTH channel and toNodeId (XOR) before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(
makeClient(gw.baseUrl).send({ text: 'x', channel: 3, toNodeId: '!1' }),
).rejects.toThrow(/mutually exclusive/);
expect(gw.requests).toHaveLength(0);
});
it('rejects a send with NEITHER channel nor toNodeId (XOR) before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(makeClient(gw.baseUrl).send({ text: 'x' })).rejects.toThrow(
/required \(XOR\)/,
);
expect(gw.requests).toHaveLength(0);
});
it('rejects an empty text before any request', async () => {
gw = await startGateway({
[route('POST', '/messages')]: { body: { success: true } },
});
await expect(
makeClient(gw.baseUrl).send({ text: '', channel: 3 }),
).rejects.toThrow(/non-empty/);
expect(gw.requests).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Error handling (non-2xx → MeshMonitorError with the HTTP status)
// ---------------------------------------------------------------------------
describe('HttpMeshMonitorClient error handling', () => {
for (const status of [401, 413, 429, 503]) {
it(`maps HTTP ${status} to a MeshMonitorError carrying the status`, async () => {
gw = await startGateway({
[route('GET', '/status')]: {
status,
body: { success: false, error: `code ${status}` },
},
});
const err = await makeClient(gw.baseUrl)
.getStatus()
.catch((e) => e);
expect(err).toBeInstanceOf(MeshMonitorError);
expect(err.status).toBe(status);
expect(err.message).toContain(`HTTP ${status}`);
});
}
it('truncates a long error body to 300 chars', async () => {
const longMsg = 'x'.repeat(500);
gw = await startGateway({
[route('GET', '/status')]: {
status: 500,
body: { success: false, error: longMsg },
},
});
const err = await makeClient(gw.baseUrl)
.getStatus()
.catch((e) => e);
expect(err).toBeInstanceOf(MeshMonitorError);
expect(err.status).toBe(500);
expect((err.body ?? '').length).toBe(300);
});
});
// ---------------------------------------------------------------------------
// createMeshMonitorClientFromEnv
// ---------------------------------------------------------------------------
describe('createMeshMonitorClientFromEnv', () => {
it('returns null when no token is configured', () => {
delete process.env.MESHMONITOR_API_TOKEN;
expect(createMeshMonitorClientFromEnv()).toBeNull();
});
it('builds an HttpMeshMonitorClient from env with default base + source', () => {
process.env.MESHMONITOR_API_TOKEN = TOKEN;
const c = createMeshMonitorClientFromEnv();
expect(c).toBeInstanceOf(HttpMeshMonitorClient);
});
it('honours MESHMONITOR_API_BASE and MESHMONITOR_SOURCE overrides without throwing', () => {
process.env.MESHMONITOR_API_TOKEN = TOKEN;
process.env.MESHMONITOR_API_BASE = 'http://example.org/base/';
process.env.MESHMONITOR_SOURCE = 'custom-src';
expect(createMeshMonitorClientFromEnv()).toBeInstanceOf(
HttpMeshMonitorClient,
);
});
});
+547
View File
@@ -0,0 +1,547 @@
/**
* MeshMonitor HTTP gateway client — a stateless REST replacement for the raw
* Meshtastic protobuf-over-TCP bridge. Every request just carries
* `Authorization: Bearer <token>` (no session/handshake/subscribe).
*
* The OpenAPI spec served by the gateway is OUTDATED — the live JSON shapes
* confirmed by curl (see the notes below and the spec in this module's tests)
* differ from it. Trust the mappers here:
* - canonical resource paths are source-scoped: `/sources/{sourceId}/…`
* (the legacy root `/channels` and `/telemetry` 404 on this deployment)
* - node position is FLAT: `latitude`/`longitude`/`altitude` (no nested `position{}`)
* - node `lastHeard`/`firstHeard` are EPOCH SECONDS; `createdAt`/`positionTimestamp` are ms
* - message `timestamp`/`rxTime`/`createdAt` are EPOCH MILLISECONDS
* - telemetry is LONG-format: one row per metric with `telemetryType`/`value`/`unit`
* - a message's packet id is the 3rd segment of its `id` string
* (`sourceId_fromNodeNum_packetId`) and equals `requestId` for outbound
*/
export const BROADCAST_ADDR = 0xffffffff;
/** uint32 node number → Meshtastic `!hex` node id (3994646728 → `!ee1978c8`). */
export function nodeNumToId(num: number): string {
return '!' + (num >>> 0).toString(16).padStart(8, '0');
}
/** `!hex` node id → uint32 node number (the inverse of {@link nodeNumToId}). */
export function nodeIdToNum(id: string): number | null {
const m = /^!([0-9a-fA-F]{1,8})$/.exec(id.trim());
if (!m) return null;
return parseInt(m[1], 16) >>> 0;
}
/**
* Shared send-target validation: the gateway requires EXACTLY ONE of
* `channel` (broadcast to a channel) or `toNodeId` (direct message) — an
* XOR. Enforced here so both the HTTP client and the test fake fail
* identically on a malformed send.
*/
export function assertSendTarget(msg: {
text: string;
toNodeId?: string;
channel?: number;
}): void {
if (typeof msg.text !== 'string' || msg.text.length === 0)
throw new Error('text must be a non-empty string');
const hasChannel = msg.channel != null;
const hasTo = msg.toNodeId != null;
if (hasChannel && hasTo)
throw new Error('channel and toNodeId are mutually exclusive (XOR)');
if (!hasChannel && !hasTo)
throw new Error('one of channel or toNodeId is required (XOR)');
}
// ---------------------------------------------------------------------------
// Typed shapes the downstream MeshService consumes (gateway JSON → these)
// ---------------------------------------------------------------------------
/** The source's health / identity. */
export interface MMStatus {
/** Whether the physical device behind the source is currently connected. */
connected: boolean;
/** The physical node number the source relays through (the bot's "my" node). */
myNodeNum: number;
longName?: string;
nodeResponsive?: boolean;
}
/** One mesh node as the gateway reports it. */
export interface MMNode {
/** uint32 node number. */
num: number;
longName?: string;
shortName?: string;
/** meshtastic.HardwareModel enum VALUE (int) — the service maps it to a name. */
hwModel?: number;
/** 0-100 = battery percent (device convention: >100 = mains powered). */
batteryLevel?: number;
voltage?: number;
firmwareVersion?: string;
rssi?: number;
snr?: number;
/** EPOCH SECONDS of the last contact — same unit as the existing NodeSummary.lastHeard. Omitted when unknown/never heard. */
lastHeard?: number;
/** Only present when both coordinates are known. */
position?: { lat: number; lon: number; alt?: number };
hopsAway?: number;
/** True when the node info arrived via MQTT rather than LoRa. */
viaMqtt?: boolean;
}
/** A configured channel; the psk is ALWAYS redacted. */
export interface MMChannel {
index: number;
name: string;
/** Always the redaction marker — the raw key never leaves the client. */
psk?: string;
uplinkEnabled?: boolean;
downlinkEnabled?: boolean;
}
/** A telemetry reading (one per metric, from the gateway's long-format feed). */
export interface MMTelemetry {
fromNum: number;
/** EPOCH MILLISECONDS. */
at: number;
/** The metric name (e.g. "airUtilTx", "altitude", "batteryLevel"). */
kind: string;
/** The metric payload: the row's value + unit (plus other non-null fields). */
data: object;
}
/** A mesh message (inbound or outbound), newest first. */
export interface MMMessage {
/** uint32 packet id — the 3rd segment of the gateway `id` (== `requestId` for outbound). */
packetId: number;
fromNum?: number;
fromId?: string;
toNum?: number;
toId?: string;
text: string;
/** Channel index; -1 = direct message. */
channel: number;
portnum?: number;
/** Outbound only: the packet id used to match delivery ACKs. */
requestId?: number;
/** The packet id this message quotes / replies to (threaded quote). */
replyId?: number;
/** EPOCH MILLISECONDS. */
at: number;
snr?: number;
viaMqtt?: boolean;
deliveryState?: 'pending' | 'delivered' | 'confirmed' | 'failed' | null;
}
/**
* The transport the MeshService talks to. Phase 2 wires the real
* {@link HttpMeshMonitorClient} (or the test {@link FakeMeshMonitorClient})
* into the service; this interface is the stable contract between them.
*/
export interface MeshMonitorClient {
/** Source health / identity (`GET /sources/{id}/status`). */
getStatus(): Promise<MMStatus>;
/** All known nodes (`GET /sources/{id}/nodes`). */
getNodes(): Promise<MMNode[]>;
/** One node by node number, numeric string, or `!hex` id; null when missing. */
getNode(nodeIdOrNum: string | number): Promise<MMNode | null>;
/** Configured channels, psk redacted (`GET /sources/{id}/channels`). */
getChannels(): Promise<MMChannel[]>;
/** Telemetry readings (`GET /sources/{id}/telemetry`). */
getTelemetry(): Promise<MMTelemetry[]>;
/**
* Recent messages, newest first (`GET /sources/{id}/messages`). `since` is
* ignored by this deployment — callers dedupe by `packetId`/`id`.
*/
getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]>;
/**
* Send a text message (`POST /sources/{id}/messages`). Exactly one of
* `toNodeId` (DM) or `channel` (broadcast) is required (XOR); `replyId`
* quotes an earlier packet. Returns the assigned packet id, delivery state
* and the gateway's message id.
*/
send(msg: {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}): Promise<{ packetId: number; deliveryState: string; messageId?: string }>;
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/** A non-2xx response from the gateway. `status` is the HTTP status; `body` the (truncated) server payload. */
export class MeshMonitorError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: string,
) {
super(message);
this.name = 'MeshMonitorError';
}
}
// ---------------------------------------------------------------------------
// Raw gateway JSON shapes (confirmed live — the spec is outdated)
// ---------------------------------------------------------------------------
interface RawStatus {
localNodeNum?: number;
localNodeId?: string;
longName?: string | null;
shortName?: string | null;
connected?: boolean;
nodeResponsive?: boolean;
}
interface RawNode {
nodeNum?: number;
nodeId?: string;
longName?: string | null;
shortName?: string | null;
hwModel?: number | null;
role?: number | null;
batteryLevel?: number | null;
voltage?: number | null;
firmwareVersion?: string | null;
rssi?: number | null;
snr?: number | null;
/** EPOCH SECONDS (not ms). */
lastHeard?: number | null;
latitude?: number | null;
longitude?: number | null;
altitude?: number | null;
hopsAway?: number | null;
viaMqtt?: boolean | null;
}
interface RawChannel {
id?: number;
name?: string | null;
displayName?: string | null;
role?: number | null;
uplinkEnabled?: boolean | null;
downlinkEnabled?: boolean | null;
psk?: string | null;
pskSet?: boolean | null;
}
interface RawTelemetry {
nodeNum?: number | null;
nodeId?: string | null;
telemetryType?: string | null;
/** EPOCH MILLISECONDS. */
timestamp?: number | null;
value?: unknown;
unit?: string | null;
packetId?: number | null;
channel?: number | null;
gpsAccuracy?: number | null;
rxSnr?: number | null;
}
interface RawMessage {
id?: string | null;
fromNodeNum?: number | null;
fromNodeId?: string | null;
toNodeNum?: number | null;
toNodeId?: string | null;
text?: string | null;
channel?: number | null;
portnum?: number | null;
requestId?: number | null;
replyId?: number | null;
/** EPOCH MILLISECONDS. */
timestamp?: number | null;
rxSnr?: number | null;
viaMqtt?: boolean | null;
deliveryState?: string | null;
}
// ---------------------------------------------------------------------------
// Mappers (raw JSON → typed shapes)
// ---------------------------------------------------------------------------
function mapStatus(d: RawStatus): MMStatus {
const out: MMStatus = {
connected: d.connected ?? false,
myNodeNum: d.localNodeNum ?? 0,
};
if (d.longName != null) out.longName = d.longName;
if (d.nodeResponsive != null) out.nodeResponsive = d.nodeResponsive;
return out;
}
function mapNode(n: RawNode): MMNode {
const out: MMNode = { num: n.nodeNum ?? 0 };
if (n.longName) out.longName = n.longName;
if (n.shortName) out.shortName = n.shortName;
if (n.hwModel != null) out.hwModel = n.hwModel;
if (n.batteryLevel != null) out.batteryLevel = n.batteryLevel;
if (n.voltage != null) out.voltage = n.voltage;
if (n.firmwareVersion) out.firmwareVersion = n.firmwareVersion;
if (n.rssi != null) out.rssi = n.rssi;
if (n.snr != null) out.snr = n.snr;
// gateway lastHeard is epoch SECONDS and 0 = never heard — keep the unit, drop zero
if (n.lastHeard != null && n.lastHeard > 0) out.lastHeard = n.lastHeard;
if (n.hopsAway != null) out.hopsAway = n.hopsAway;
if (n.viaMqtt === true) out.viaMqtt = true;
// flat coordinates (NOT a nested position{} object) — present only when both are known
if (n.latitude != null && n.longitude != null) {
out.position = { lat: n.latitude, lon: n.longitude };
if (n.altitude != null) out.position.alt = n.altitude;
}
return out;
}
function mapChannel(c: RawChannel): MMChannel {
const out: MMChannel = {
index: c.id ?? 0,
// the gateway's configured name ("" for the unconfigured primary); it also
// exposes a human-readable `displayName` (e.g. "Primary") if ever needed
name: c.name ?? '',
};
// the psk (base64 key) is never exposed — replaced with a marker when set
if (c.psk != null) out.psk = 'REDACTED';
if (c.uplinkEnabled != null) out.uplinkEnabled = c.uplinkEnabled;
if (c.downlinkEnabled != null) out.downlinkEnabled = c.downlinkEnabled;
return out;
}
function mapTelemetry(t: RawTelemetry): MMTelemetry {
const data: Record<string, unknown> = {};
if (t.value !== undefined && t.value !== null) data.value = t.value;
if (t.unit != null) data.unit = t.unit;
if (t.packetId != null) data.packetId = t.packetId;
if (t.channel != null) data.channel = t.channel;
if (t.gpsAccuracy != null) data.gpsAccuracy = t.gpsAccuracy;
if (t.rxSnr != null) data.rxSnr = t.rxSnr;
return {
fromNum: t.nodeNum ?? 0,
at: t.timestamp ?? 0, // epoch ms
kind: t.telemetryType ?? '',
data,
};
}
/** Packet id = 3rd segment of the gateway `id` (`sourceId_fromNodeNum_packetId`). */
function parsePacketId(id?: string | null): number {
if (!id) return 0;
const last = id.split('_').pop() ?? '';
const n = Number(last);
return Number.isFinite(n) ? n : 0;
}
function mapMessage(m: RawMessage): MMMessage {
const out: MMMessage = {
packetId: parsePacketId(m.id),
text: m.text ?? '',
channel: m.channel ?? 0,
at: m.timestamp ?? 0, // epoch ms
};
if (m.fromNodeNum != null) out.fromNum = m.fromNodeNum;
if (m.fromNodeId != null) out.fromId = m.fromNodeId;
if (m.toNodeNum != null) out.toNum = m.toNodeNum;
if (m.toNodeId != null) out.toId = m.toNodeId;
if (m.portnum != null) out.portnum = m.portnum;
if (m.requestId != null) out.requestId = m.requestId;
if (m.replyId != null) out.replyId = m.replyId;
if (m.rxSnr != null) out.snr = m.rxSnr;
if (m.viaMqtt === true) out.viaMqtt = true;
if (m.deliveryState != null)
out.deliveryState = m.deliveryState as MMMessage['deliveryState'];
return out;
}
// ---------------------------------------------------------------------------
// HTTP implementation
// ---------------------------------------------------------------------------
export interface HttpMeshMonitorClientOptions {
/** Base URL of the gateway API, e.g. `https://meshmonitor.kkhost.pl/api/v1`. */
baseUrl: string;
/** Source id (a UUID, or the literal `default` alias). */
sourceId: string;
/** Bearer token (`mm_v1_…`). */
token: string;
/** Per-request timeout in ms (default 15000). */
timeoutMs?: number;
/** `GET /nodes?active=` (default true). */
nodeActive?: boolean;
/** `GET /nodes?sinceDays=` (default 7). */
nodeSinceDays?: number;
}
/**
* Native-`fetch` MeshMonitor client. Stateless — every request carries the
* Bearer token; there is no session to establish or keep alive.
*/
export class HttpMeshMonitorClient implements MeshMonitorClient {
private readonly baseUrl: string;
private readonly sourceId: string;
private readonly token: string;
private readonly timeoutMs: number;
private readonly nodeActive: boolean;
private readonly nodeSinceDays: number;
constructor(opts: HttpMeshMonitorClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
this.sourceId = opts.sourceId;
this.token = opts.token;
this.timeoutMs = opts.timeoutMs ?? 15000;
this.nodeActive = opts.nodeActive ?? true;
this.nodeSinceDays = opts.nodeSinceDays ?? 7;
}
/** Issue a request and parse the JSON envelope; throw {@link MeshMonitorError} on non-2xx. */
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${path}`;
const res = await fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
authorization: `Bearer ${this.token}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!res.ok) {
const body = (await res.text().catch(() => '')).slice(0, 300);
throw new MeshMonitorError(
`MeshMonitor ${init?.method ?? 'GET'} ${path} failed: HTTP ${res.status}${body ? ` ${body}` : ''}`,
res.status,
body,
);
}
return (await res.json()) as T;
}
async getStatus(): Promise<MMStatus> {
const body = await this.request<{ data?: RawStatus }>(
`/sources/${this.sourceId}/status`,
);
return mapStatus(body.data ?? {});
}
async getNodes(): Promise<MMNode[]> {
const q = new URLSearchParams();
if (this.nodeActive) q.set('active', 'true');
if (this.nodeSinceDays != null)
q.set('sinceDays', String(this.nodeSinceDays));
const qs = q.toString();
const body = await this.request<{ data?: RawNode[] }>(
`/sources/${this.sourceId}/nodes${qs ? `?${qs}` : ''}`,
);
return (body.data ?? []).map(mapNode);
}
async getNode(nodeIdOrNum: string | number): Promise<MMNode | null> {
const id =
typeof nodeIdOrNum === 'number' ? nodeNumToId(nodeIdOrNum) : nodeIdOrNum;
try {
const body = await this.request<{ data?: RawNode | null }>(
`/sources/${this.sourceId}/nodes/${encodeURIComponent(id.trim())}`,
);
return body.data ? mapNode(body.data) : null;
} catch (err) {
// an unknown node is a 404 — surface it as "no such node", not an error
if (err instanceof MeshMonitorError && err.status === 404) return null;
throw err;
}
}
async getChannels(): Promise<MMChannel[]> {
const body = await this.request<{ data?: RawChannel[] }>(
`/sources/${this.sourceId}/channels`,
);
return (body.data ?? []).map(mapChannel).sort((a, b) => a.index - b.index);
}
async getTelemetry(): Promise<MMTelemetry[]> {
const body = await this.request<{ data?: RawTelemetry[] }>(
`/sources/${this.sourceId}/telemetry`,
);
return (body.data ?? []).map(mapTelemetry);
}
async getMessages(opts?: {
limit?: number;
channel?: number;
toNodeId?: string;
fromNodeId?: string;
}): Promise<MMMessage[]> {
const q = new URLSearchParams();
if (opts?.limit != null) q.set('limit', String(opts.limit));
if (opts?.channel != null) q.set('channel', String(opts.channel));
if (opts?.toNodeId != null) q.set('toNodeId', opts.toNodeId);
if (opts?.fromNodeId != null) q.set('fromNodeId', opts.fromNodeId);
const qs = q.toString();
const body = await this.request<{ data?: RawMessage[] }>(
`/sources/${this.sourceId}/messages${qs ? `?${qs}` : ''}`,
);
// the gateway returns newest first — keep that order
return (body.data ?? []).map(mapMessage);
}
async send(msg: {
text: string;
toNodeId?: string;
channel?: number;
replyId?: number;
}): Promise<{ packetId: number; deliveryState: string; messageId?: string }> {
assertSendTarget(msg);
const body: Record<string, unknown> = { text: msg.text };
if (msg.channel != null) body.channel = msg.channel;
if (msg.toNodeId != null) body.toNodeId = msg.toNodeId;
if (msg.replyId != null) body.replyId = msg.replyId;
const res = await this.request<{
data?: {
messageId?: string;
requestId?: number;
deliveryState?: string;
};
}>(`/sources/${this.sourceId}/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const d = res.data ?? {};
const out: {
packetId: number;
deliveryState: string;
messageId?: string;
} = {
// 201 carries the packet id as requestId; a 202 (auto-split/queued) may omit it → 0
packetId: d.requestId ?? 0,
deliveryState: d.deliveryState ?? 'pending',
};
if (d.messageId != null) out.messageId = d.messageId;
return out;
}
}
/**
* Build the real client from the environment, or `null` when
* `MESHMONITOR_API_TOKEN` is unset (the caller then keeps the TCP bridge).
* Used by Phase 2 to hand MeshService a client instance.
*/
export function createMeshMonitorClientFromEnv(): MeshMonitorClient | null {
const token = process.env.MESHMONITOR_API_TOKEN;
if (!token) return null;
return new HttpMeshMonitorClient({
baseUrl:
process.env.MESHMONITOR_API_BASE ??
'https://meshmonitor.kkhost.pl/api/v1',
sourceId: process.env.MESHMONITOR_SOURCE ?? 'default',
token,
timeoutMs: parseInt(process.env.MESHMONITOR_TIMEOUT_MS ?? '15000', 10),
});
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import {
createMeshMonitorClientFromEnv,
type MeshMonitorClient,
} from './mesh-monitor-client.js';
/**
* DI token for the MeshMonitor client. Phase 2 imports {@link MeshMonitorModule}
* into `MeshModule` and injects this token to decide between the HTTP gateway
* (token set → a real {@link MeshMonitorClient}) and the legacy TCP bridge
* (token unset → `null`).
*/
export const MESH_MONITOR_CLIENT = 'MESH_MONITOR_CLIENT';
/**
* Thin provider around {@link createMeshMonitorClientFromEnv()}. It builds the
* real `HttpMeshMonitorClient` from the environment
* (`MESHMONITOR_API_TOKEN`, `MESHMONITOR_API_BASE`, `MESHMONITOR_SOURCE`,
* `MESHMONITOR_TIMEOUT_MS`) or `null` when no token is configured — the caller
* then keeps the existing TCP bridge. Not imported anywhere yet: Phase 2 wires
* it into `MeshModule`.
*/
@Module({
providers: [
{
provide: MESH_MONITOR_CLIENT,
useFactory: (): MeshMonitorClient | null =>
createMeshMonitorClientFromEnv(),
},
],
exports: [MESH_MONITOR_CLIENT],
})
export class MeshMonitorModule {}
+342
View File
@@ -0,0 +1,342 @@
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/api/session/ses_pinned/prompt`);
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 {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered
expect(String(opts.body)).toBe(
JSON.stringify({
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({
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/api/session/ses_pinned/prompt`,
);
});
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/api/session/ses_pinned/prompt`,
);
});
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(
// v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
// directory, so the location.directory filter must exclude it
json({
data: [
{
id: 'ses_old',
location: { directory: WS_PATH },
time: { updated: 100 },
},
{
id: 'ses_other_ws',
location: { directory: '/elsewhere' },
time: { updated: 300 },
},
{
id: 'ses_new',
location: { directory: WS_PATH },
time: { updated: 200 },
},
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
// discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/api/session/ses_new/prompt`,
);
});
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({
data: [
{ id: 'ses_a', location: { directory: WS_PATH } },
{ id: 'ses_b', location: { directory: WS_PATH }, time: {} },
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/api/session/ses_a/prompt`,
);
});
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 + prompt POST only — no /api/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/api/session/ses_pinned/prompt`,
);
});
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 prompt POST times out — a read timeout is still treated as delivered (safety net)
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',
);
});
});
+181
View File
@@ -0,0 +1,181 @@
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 v2 returns 2xx immediately (it no longer holds the response until the agent turn finishes);
// the 15 s timeout only guards a stuck connection — a read timeout is still treated as delivered
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}/api/session (v2; only the fields we use — directory lives under location, time is epoch ms). */
interface SessionEntry {
id: string;
title?: string;
location?: { directory?: 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 (v2: POST {base}/api/session/{id}/prompt with a
* `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means
* DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) 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) {
// v2 discovery: GET {base}/api/session returns an envelope { data, cursor } — NOT a bare array
const envelope = await this.getJson<{ data?: SessionEntry[] }>(
`${base}/api/session`,
);
// only sessions of THIS workspace directory — the instance proxy may serve other sessions too
const sessions = (envelope.data ?? []).filter(
(s) => s.location?.directory === this.workspacePath,
);
if (sessions.length === 0)
throw new Error(
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
);
// most recently updated session (epoch ms); 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}/api/session/${sid}/prompt`, {
method: 'POST',
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ 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 — kept as a harmless safety net: v2 normally
// returns 2xx immediately, but if the response is still pending past the timeout we treat the
// message as accepted and never retry
if ((err as Error).name === 'TimeoutError') {
this.logger.log(
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
);
return;
}
throw err;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { parseFeed } from './rss.feed.js';
const RSS2 = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Some Feed</title>
<item>
<title>First headline</title>
<link>https://example.com/first</link>
</item>
<item>
<title><![CDATA[Second: with CDATA]]></title>
<description>ignored</description>
</item>
<item>
<title></title>
<link>https://example.com/empty-title</link>
</item>
</channel>
</rss>`;
const ATOM = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>An Atom feed</title>
<entry>
<title>Only entry</title>
<link rel="alternate" href="https://example.com/atom"/>
</entry>
</feed>`;
describe('parseFeed', () => {
it('parses RSS 2.0 items in document order, keeping links where present and skipping empty titles', () => {
expect(parseFeed(RSS2)).toEqual([
{ title: 'First headline', link: 'https://example.com/first' },
{ title: 'Second: with CDATA' },
]);
});
it('parses Atom entries (single entry object, link as href attribute)', () => {
expect(parseFeed(ATOM)).toEqual([
{ title: 'Only entry', link: 'https://example.com/atom' },
]);
});
it('returns [] for malformed XML', () => {
expect(parseFeed('this is not xml at all')).toEqual([]);
expect(parseFeed('<rss><channel><item><title>broken')).toEqual([]);
});
it('returns [] when there are no items or none with a usable title', () => {
expect(
parseFeed('<rss version="2.0"><channel><title>x</title></channel></rss>'),
).toEqual([]);
expect(
parseFeed('<feed xmlns="http://www.w3.org/2005/Atom"></feed>'),
).toEqual([]);
expect(
parseFeed(
'<rss version="2.0"><channel><item><title> </title></item></channel></rss>',
),
).toEqual([]);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { XMLParser } from 'fast-xml-parser';
/** One feed item: title always present (non-empty), link where the feed provides one. */
export interface FeedItem {
title: string;
link?: string;
}
const parser = new XMLParser({ ignoreAttributes: false });
function asText(value: unknown): string | undefined {
if (typeof value === 'string') return value.trim() || undefined;
// element with attributes/CDATA parses to an object holding its text under #text
const t = (value as Record<string, unknown>)?.['#text'];
if (typeof t === 'string' && t.trim()) return t.trim();
return undefined;
}
function asLink(value: unknown): string | undefined {
const fromText = asText(value);
if (fromText) return fromText; // RSS 2.0 <link>url</link>
// Atom <link rel="alternate" href="..."/> — the URL is an attribute
const href = (value as Record<string, unknown>)?.['@_href'];
if (typeof href === 'string' && href.trim()) return href.trim();
return undefined;
}
function collect(value: unknown): Record<string, unknown>[] {
// a single item/entry parses to an object, several to an array
if (!value) return [];
const arr = Array.isArray(value) ? value : [value];
return arr.filter(
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
);
}
/**
* Parse RSS 2.0 (`<item><title>/<link>`) or Atom (`<entry><title>`) XML into items with
* non-empty titles, in document order (feeds are newest-first). Returns [] for malformed
* input or feeds without usable items — that empty result IS the "not a valid feed" signal.
*/
export function parseFeed(xml: string): FeedItem[] {
let doc: unknown;
try {
doc = parser.parse(xml);
} catch {
return []; // fast-xml-parser is lenient, but a hard failure means no items either
}
const root = doc as Record<string, any>;
// coalesce the RAW values first — collect() always returns an array, so `collect(a) ?? collect(b)` could never fall through to b
const rawItems = collect(root?.rss?.channel?.item ?? root?.feed?.entry);
const items: FeedItem[] = [];
for (const item of rawItems) {
const title = asText(item?.title);
if (!title) continue; // skip entries without a usable title
const link = asLink(item.link);
items.push(link ? { title, link } : { title });
}
return items;
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { LlmModule } from '../llm/llm.module.js';
import { LlmService } from '../llm/llm.service.js';
import { RssService } from './rss.service.js';
// The rss_sources table lives in the SAME file as the mesh message history (MESH_DB_PATH) — a second
// connection is fine, SQLite serializes writes. The service initializes its own table on construction.
function openRssDb(): DatabaseSync {
const path = process.env.MESH_DB_PATH ?? './data/mesh-bot.db';
mkdirSync(dirname(path), { recursive: true });
return new DatabaseSync(path);
}
@Module({
imports: [LlmModule], // one-way — LlmService has no dependencies of its own
providers: [
{
provide: RssService,
useFactory: (llm: LlmService) => new RssService(llm, openRssDb()),
inject: [LlmService],
},
],
exports: [RssService],
})
export class RssModule {}
+317
View File
@@ -0,0 +1,317 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js';
import { RssStore } from './rss.store.js';
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
const FEED_A = `<rss version="2.0"><channel>
<item><title>Alpha headline</title></item>
<item><title>Beta headline</title></item>
</channel></rss>`;
const FEED_B = `<rss version="2.0"><channel>
<item><title>Gamma headline</title></item>
</channel></rss>`;
function feedResponse(xml: string): Response {
return { ok: true, status: 200, text: async () => xml } as Response;
}
describe('RssService.handle', () => {
let dir: string;
let dbFile: string;
let llm: { chat: ReturnType<typeof vi.fn> };
let svc: RssService;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-svc-'));
dbFile = join(dir, 'test.db');
llm = { chat: vi.fn() };
svc = new RssService(
llm as unknown as LlmService,
new DatabaseSync(dbFile),
);
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
rmSync(dir, { recursive: true, force: true });
});
const ctx = (text: string) => ({ text });
// second connection — the same shape production uses for a separate table in one file
const stored = () => new RssStore(new DatabaseSync(dbFile));
it('replies with usage on bare #rss and on unknown subcommands', async () => {
const help = await svc.handle(ctx('#rss'));
expect(help).toContain('#rss add <url> <name>');
expect(help).toContain('#rss get <name|#tag>');
expect(await svc.handle(ctx('#rss frobnicate'))).toBe(help);
});
it('add: validates the feed, stores source with LLM tags, replies added (tags: ...)', async () => {
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce('#AI, News');
const reply = await svc.handle(
ctx('#rss add https://a.example/rss my feed'),
);
expect(reply).toBe('added my feed (tags: ai, news)');
expect(stored().get('my feed')).toMatchObject({
url: 'https://a.example/rss',
tags: ['ai', 'news'],
});
const [messages, opts] = llm.chat.mock.calls[0];
expect(opts).toEqual({ maxTokens: 50 });
expect(messages[0].content).toContain('Alpha headline');
});
it('add: LLM failure still stores the source with empty tags and says so', async () => {
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockRejectedValueOnce(new Error('llm down'));
const reply = await svc.handle(
ctx('#rss add https://a.example/rss myfeed'),
);
expect(reply).toBe('added myfeed (tag generation failed - no tags)');
expect(stored().get('myfeed')?.tags).toEqual([]);
});
it('add: rejects invalid urls without fetching or storing', async () => {
expect(await svc.handle(ctx('#rss add not a url x'))).toBe('invalid url');
expect(await svc.handle(ctx('#rss add ftp://a.example/rss x'))).toBe(
'invalid url',
);
expect(fetchMock).not.toHaveBeenCalled();
expect(stored().list()).toEqual([]);
});
it('add: rejects duplicate names case-insensitively without fetching', async () => {
stored().add({ name: 'MyFeed', url: 'https://old.example/rss', tags: [] });
const reply = await svc.handle(
ctx('#rss add https://a.example/rss myfeed'),
);
expect(reply).toBe('myfeed already exists - remove it first');
expect(fetchMock).not.toHaveBeenCalled();
expect(stored().get('myfeed')?.url).toBe('https://old.example/rss'); // untouched
});
it('add: reports non-2xx and unparseable feeds, stores nothing', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
expect(await svc.handle(ctx('#rss add https://a.example/rss x'))).toBe(
'feed fetch failed (HTTP 404)',
);
fetchMock.mockResolvedValueOnce(feedResponse('this is not xml'));
expect(await svc.handle(ctx('#rss add https://b.example/rss y'))).toBe(
'not a valid feed (no items)',
);
expect(stored().list()).toEqual([]);
});
it('remove: deletes case-insensitively, reports missing names', async () => {
stored().add({ name: 'Gone', url: 'https://g.example/rss', tags: [] });
expect(await svc.handle(ctx('#rss remove gone'))).toBe('removed gone');
expect(stored().list()).toEqual([]);
expect(await svc.handle(ctx('#rss remove nope'))).toBe(
'no source named nope',
);
});
it('tags: no tags yet when empty, distinct sorted list otherwise', async () => {
expect(await svc.handle(ctx('#rss tags'))).toBe('no tags yet');
stored().add({
name: 'A',
url: 'https://a.example/rss',
tags: ['zeta', 'alpha'],
});
stored().add({
name: 'B',
url: 'https://b.example/rss',
tags: ['beta', 'alpha'],
});
expect(await svc.handle(ctx('#rss tags'))).toBe('alpha, beta, zeta');
});
it('sources: no sources yet when empty, names otherwise', async () => {
expect(await svc.handle(ctx('#rss sources'))).toBe('no sources yet');
stored().add({ name: 'Bee', url: 'https://b.example/rss', tags: [] });
stored().add({ name: 'Aye', url: 'https://a.example/rss', tags: [] });
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
});
it('get <name>: summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: Riverfront expansion approved.',
]);
const [messages, opts] = llm.chat.mock.calls[0];
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
expect(opts).toEqual({ maxTokens: 2500 });
const prompt = messages[0].content;
expect(prompt).toContain('exactly 3-5 lines');
expect(prompt).toContain('<short header>: <one or two short sentences>');
expect(prompt).toContain('at most 20 characters');
expect(prompt).toContain('No intro, no outro, no numbering');
expect(prompt).toContain('Alpha headline');
expect(prompt).toContain('Beta headline');
});
it('get <name>: caps items at 5', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item ${c}`)
.join('\n');
llm.chat.mockResolvedValueOnce(seven);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'a: item a',
'b: item b',
'c: item c',
'd: item d',
'e: item e',
]);
});
it('get <name>: drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: expansion approved.',
]);
});
it('get <name>: hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(Array.isArray(reply)).toBe(true);
const [cut, ok] = reply as string[];
expect(cut.length).toBeLessThanOrEqual(180);
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
expect(cut.endsWith(' ')).toBe(false);
expect(ok).toBe('Park: short line.'); // other lines untouched
});
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
stored().add({ name: 'C', url: 'https://c.example/rss', tags: ['other'] }); // not matched by #tech
fetchMock
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
.mockRejectedValueOnce(new Error('down')); // B is dead
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
const reply = await svc.handle(ctx('#rss get #tech'));
// B's fetch rejected, yet the summary still went out from A's titles alone
expect(reply).toEqual(['Chips: only alpha survived.']);
expect(llm.chat).toHaveBeenCalledTimes(1);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
});
it('get #tag: merges titles from every source carrying the tag', async () => {
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
fetchMock
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
llm.chat.mockResolvedValueOnce(
'Alpha: first headline.\nGamma: second headline.',
);
const reply = await svc.handle(ctx('#rss get #tech'));
expect(reply).toEqual([
'Alpha: first headline.',
'Gamma: second headline.',
]);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
});
it('get: unknown name and tag get distinct error replies', async () => {
expect(await svc.handle(ctx('#rss get nope'))).toBe('no source named nope');
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
expect(await svc.handle(ctx('#rss get #nothing'))).toBe(
'no sources with tag nothing',
);
});
});
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
expect(
parseSummaryItems(
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
),
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
});
it('caps at 5 items', () => {
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item`)
.join('\n');
expect(parseSummaryItems(seven)).toHaveLength(5);
expect(parseSummaryItems(seven)[4]).toBe('e: item');
});
it('returns [] when nothing is valid', () => {
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
});
});
describe('fitOneLine (one mesh packet per line)', () => {
it('passes through lines that already fit, including exactly 185 chars', () => {
const short = 'Chips: EU funds foundries.';
expect(fitOneLine(short)).toBe(short);
const exact = 'x'.repeat(185);
expect(fitOneLine(exact)).toBe(exact);
});
it('cuts over-long lines at a word boundary near 180', () => {
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
const cut = fitOneLine(long);
expect(cut.length).toBeLessThanOrEqual(180);
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
expect(cut.endsWith(' ')).toBe(false);
});
it('hard-cuts at 180 when there is no word boundary in reach', () => {
const long = 'w'.repeat(250);
expect(fitOneLine(long)).toBe('w'.repeat(180));
});
});
+237
View File
@@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseSync } from 'node:sqlite';
// type-only both ways — no runtime import cycle between src/rss and src/commands
import type { CommandContext } from '../commands/command.service.js';
import { LlmService } from '../llm/llm.service.js';
import { parseFeed } from './rss.feed.js';
import { RssStore } from './rss.store.js';
// ponytail: hardcoded tunables, promote to env if the owner wants them adjustable
const FETCH_TIMEOUT_MS = 15000; // feed fetches — same budget as the opencode notifier's POSTs
const MAX_TITLES = 8; // "up to 8 latest item titles" per the design
const TAG_MAX_TOKENS = 50;
// The LLM server (qwen3.8-27b) emits reasoning_content and those tokens COUNT against max_tokens
// (verified: cap 150 → completion_tokens 150 of which 105 were reasoning, finish_reason 'length').
// Thinking length for this task is unbounded and uncontrollable server-side (/no_think,
// chat_template_kwargs.enable_thinking and thinking_budget are all ignored) — observed 32..1776
// tokens across runs. So the cap must be headroom for reasoning + ~80 content tokens:
// 2500 covers every observed run (worst case ≈42 s at ~60 tok/s, under LLM_TIMEOUT_MS=60 s).
const SUMMARY_MAX_TOKENS = 2500;
// Mesh fragments replies longer than ~190 chars into fire-and-forget packets that recipients
// often never get — each summary line must fit ONE packet on its own. The prompt asks for short
// headered lines; this is the hard backstop if the model ignores it (cut at a word boundary).
const MAX_LINE_CHARS = 185; // per-line one-packet threshold
const LINE_CUT_AT = 180; // where over-long lines get cut, snapped back to a word boundary
const MAX_ITEMS = 5; // cap on summary items sent as separate messages
/**
* The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with
* LLM-generated tags and on-demand headline summaries. Replies are short strings — or an array
* of lines for multi-message summaries, which CommandService sends one respond() per line.
*/
@Injectable()
export class RssService {
private readonly logger = new Logger(RssService.name);
private readonly store: RssStore;
constructor(
private readonly llm: LlmService,
db: DatabaseSync,
) {
this.store = new RssStore(db);
}
/** Entry point for the `#rss` prefix — always replies (errors included); `get` may reply with several lines. */
async handle(ctx: CommandContext): Promise<string | string[]> {
const words = ctx.text.trim().split(/\s+/);
switch (words[1]) {
case undefined: // bare `#rss`
case 'help':
return this.help();
case 'add':
return this.add(words.slice(2));
case 'remove':
return this.remove(words.slice(2).join(' '));
case 'tags':
return this.tags();
case 'sources':
return this.sources();
case 'get':
return this.get((words[2] ?? '').trim());
default:
return this.help(); // unknown subcommand → help
}
}
private help(): string {
return [
'#rss add <url> <name> - add source with AI tags',
'#rss remove <name> - delete a source',
'#rss get <name|#tag> - summarize latest items',
'#rss tags - all distinct tags',
'#rss sources - all source names',
].join('\n');
}
/** `#rss add <url> <name>` — name may contain spaces (everything after the url). */
private async add(args: string[]): Promise<string> {
const url = args[0];
const name = args.slice(1).join(' ').trim();
if (!url || !name) return 'usage: #rss add <url> <name>';
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return 'invalid url';
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
return 'invalid url';
if (this.store.exists(name))
return `${name} already exists - remove it first`;
let titles: string[];
try {
titles = await this.fetchLatestTitles(url);
} catch (err) {
const msg = (err as Error).message;
// fetchLatestTitles throws `HTTP <status>` on non-2xx, other errors carry their own message
return msg.startsWith('HTTP')
? `feed fetch failed (${msg})`
: 'not a valid feed';
}
if (!titles.length) return 'not a valid feed (no items)';
// AI tags — best effort: a source is still added when the LLM fails
let tags: string[] = [];
try {
const raw = await this.llm.chat(
[
{
role: 'user',
content: `Give 2-4 short lowercase tags for these RSS feed item titles. Reply with comma-separated tags only - no # symbols, no explanation:\n${titles.join('\n')}`,
},
],
{ maxTokens: TAG_MAX_TOKENS },
);
tags = sanitizeTags(raw);
} catch (err) {
this.logger.warn(
`[rss] tag generation failed for '${name}': ${(err as Error).message}`,
);
}
this.store.add({ name, url, tags });
return tags.length
? `added ${name} (tags: ${tags.join(', ')})`
: 'added ' + name + ' (tag generation failed - no tags)';
}
private remove(name: string): string {
if (!name) return 'usage: #rss remove <name>';
return this.store.remove(name)
? `removed ${name}`
: `no source named ${name}`;
}
private tags(): string {
const all = this.store.allTags();
return all.length ? all.join(', ') : 'no tags yet';
}
private sources(): string {
const names = this.store.list().map((s) => s.name);
return names.length ? names.join(', ') : 'no sources yet';
}
/** `#rss get <name>` or `#rss get #<tag>` — replies with 3-5 separate one-packet lines. */
private async get(target: string): Promise<string | string[]> {
if (!target) return 'usage: #rss get <name|#tag>';
let urls: string[];
if (target.startsWith('#')) {
const tag = target.slice(1).toLowerCase();
if (!tag) return 'usage: #rss get <name|#tag>';
const matching = this.store.list().filter((s) => s.tags.includes(tag));
if (!matching.length) return `no sources with tag ${tag}`;
urls = matching.map((s) => s.url);
} else {
const source = this.store.get(target);
if (!source) return `no source named ${target}`;
urls = [source.url];
}
// one dead feed must not sink the whole summary — collect what answers
const results = await Promise.allSettled(
urls.map((u) => this.fetchLatestTitles(u)),
);
const titles: string[] = [];
for (const r of results) {
if (r.status === 'fulfilled') titles.push(...r.value);
else
this.logger.warn(
`[rss] feed fetch failed: ${(r.reason as Error).message}`,
);
}
if (!titles.length) return 'no items found';
try {
const reply = await this.llm.chat(
[
{
role: 'user',
content: `Summarize these RSS headlines into exactly 3-5 lines, one item per line, each in the format "<short header>: <one or two short sentences>" where the header is at most 20 characters (e.g. "Culture: Museum opens new wing"). No intro, no outro, no numbering - items only:\n${titles.join('\n')}`,
},
],
{ maxTokens: SUMMARY_MAX_TOKENS },
);
// each item goes out as its own mesh message — CommandService sends the array line by line;
// fewer than 3 valid lines is fine (the model is non-deterministic), we send what we got
return parseSummaryItems(reply).map(fitOneLine);
} catch (err) {
this.logger.warn(`[rss] summary failed: ${(err as Error).message}`);
return 'summary failed';
}
}
/** Fetch a feed and return up to MAX_TITLES latest item titles; throws on non-2xx. */
private async fetchLatestTitles(url: string): Promise<string[]> {
const res = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return parseFeed(await res.text())
.slice(0, MAX_TITLES)
.map((i) => i.title);
}
}
/** Parse an LLM summary into per-line items: drop empty lines and lines without a `:` header separator, cap at MAX_ITEMS. */
export function parseSummaryItems(reply: string): string[] {
return reply
.split('\n')
.map((l) => l.trim())
.filter((l) => l && l.includes(':'))
.slice(0, MAX_ITEMS);
}
/** Hard-cut a single summary line at a word boundary near LINE_CUT_AT when it exceeds MAX_LINE_CHARS. */
export function fitOneLine(line: string): string {
if (line.length <= MAX_LINE_CHARS) return line;
const slice = line.slice(0, LINE_CUT_AT);
const at = slice.lastIndexOf(' ');
return (at > 0 ? slice.slice(0, at) : slice).trimEnd();
}
/** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */
export function sanitizeTags(raw: string): string[] {
const seen = new Set<string>();
for (const part of raw.split(',')) {
const tag = part
.trim()
.toLowerCase()
.replace(/^#+/, '')
.replace(/\s+/g, '-');
if (tag) seen.add(tag);
}
return [...seen];
}
+80
View File
@@ -0,0 +1,80 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RssStore } from './rss.store.js';
describe('RssStore (real SQLite file)', () => {
let dir: string;
let store: RssStore;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-'));
const db = new DatabaseSync(join(dir, 'test.db'));
store = new RssStore(db);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('adds and lists sources with tags round-tripped through the comma string', () => {
store.add({
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
tags: ['tech', 'news'],
});
store.add({ name: 'LWN', url: 'https://lwn.net/headlines.rss', tags: [] });
expect(store.list()).toEqual([
{
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
tags: ['tech', 'news'],
createdAt: expect.any(Number),
},
{
name: 'LWN',
url: 'https://lwn.net/headlines.rss',
tags: [],
createdAt: expect.any(Number),
},
]);
});
it('looks up and checks existence case-insensitively', () => {
store.add({ name: 'My Feed', url: 'https://example.com/rss', tags: ['a'] });
expect(store.get('my feed')?.url).toBe('https://example.com/rss');
expect(store.get('MY FEED')).toBeDefined();
expect(store.exists('mY fEeD')).toBe(true);
expect(store.exists('other')).toBe(false);
});
it('removes case-insensitively and reports whether a row went', () => {
store.add({ name: 'Gone', url: 'https://example.com/gone', tags: [] });
expect(store.remove('gone')).toBe(true);
expect(store.list()).toEqual([]);
expect(store.remove('gone')).toBe(false);
});
it('collects all distinct tags across sources, sorted', () => {
store.add({
name: 'A',
url: 'https://a.example/rss',
tags: ['zeta', 'alpha'],
});
store.add({
name: 'B',
url: 'https://b.example/rss',
tags: ['beta', 'alpha'],
});
expect(store.allTags()).toEqual(['alpha', 'beta', 'zeta']);
});
it('persists across a second connection to the same file (the production shape)', () => {
store.add({ name: 'Durable', url: 'https://d.example/rss', tags: ['x'] });
const db2 = new DatabaseSync(join(dir, 'test.db'));
expect(new RssStore(db2).get('durable')?.tags).toEqual(['x']);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { DatabaseSync } from 'node:sqlite';
/** A stored RSS source; tags are a comma-separated string in the DB, split here. */
export interface RssSource {
name: string;
url: string;
tags: string[];
createdAt: number;
}
/** Idempotent — safe to call on every open (the table lives in the shared MESH_DB_PATH file). */
export function initRssTable(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS rss_sources (
name TEXT PRIMARY KEY,
url TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
created_at INTEGER
)`);
}
/** CRUD for the rss_sources table. Names are matched case-insensitively everywhere. */
export class RssStore {
constructor(private readonly db: DatabaseSync) {
initRssTable(db);
}
list(): RssSource[] {
const rows = this.db
.prepare(
'SELECT name, url, tags, created_at FROM rss_sources ORDER BY name',
)
.all() as {
name: string;
url: string;
tags: string;
created_at: number;
}[];
return rows.map((r) => ({
name: r.name,
url: r.url,
tags: splitTags(r.tags),
createdAt: r.created_at,
}));
}
/** Case-insensitive lookup by name. */
get(name: string): RssSource | undefined {
const row = this.db
.prepare(
'SELECT name, url, tags, created_at FROM rss_sources WHERE name = ? COLLATE NOCASE',
)
.get(name) as
| { name: string; url: string; tags: string; created_at: number }
| undefined;
return (
row && {
name: row.name,
url: row.url,
tags: splitTags(row.tags),
createdAt: row.created_at,
}
);
}
/** Case-insensitive duplicate check. */
exists(name: string): boolean {
const row = this.db
.prepare('SELECT 1 FROM rss_sources WHERE name = ? COLLATE NOCASE')
.get(name);
return row !== undefined;
}
add(source: { name: string; url: string; tags: string[] }): void {
this.db
.prepare(
'INSERT INTO rss_sources (name, url, tags, created_at) VALUES (?, ?, ?, ?)',
)
.run(source.name, source.url, joinTags(source.tags), Date.now());
}
/** Case-insensitive delete; true when a row was removed. */
remove(name: string): boolean {
const result = this.db
.prepare('DELETE FROM rss_sources WHERE name = ? COLLATE NOCASE')
.run(name);
return Number(result.changes) > 0;
}
/** All distinct tags across sources, sorted. */
allTags(): string[] {
const set = new Set<string>();
for (const source of this.list()) {
for (const tag of source.tags) set.add(tag);
}
return [...set].sort();
}
}
export function splitTags(raw: string): string[] {
return raw
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
export function joinTags(tags: string[]): string {
return tags.join(',');
}
+28
View File
@@ -0,0 +1,28 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const srcDir = fileURLToPath(new URL('.', import.meta.url));
function moduleFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? moduleFiles(join(dir, entry.name))
: entry.name.endsWith('.module.ts')
? [join(dir, entry.name)]
: [],
);
}
describe('ScheduleModule.forRoot single registration', () => {
// Regression guard (v0.21.7): each forRoot() call creates its own ScheduleExplorer that scans all providers, so a second call made every @Cron/@Interval job fire twice.
it('appears exactly once across all src/**/*.module.ts', () => {
const hits = moduleFiles(srcDir).flatMap((file) =>
(
readFileSync(file, 'utf8').match(/ScheduleModule\.forRoot\(/g) ?? []
).map(() => file.slice(srcDir.length)),
);
expect(hits).toHaveLength(1);
});
});