Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7df87a5a1 | ||
|
|
2c4b4c5968 | ||
|
|
25f99f04ad | ||
|
|
f6b884dfdf | ||
|
|
f80340ae6e | ||
|
|
b54f909fbc | ||
|
|
7ec97a06d2 | ||
|
|
84915fe314 | ||
|
|
5506fb9330 | ||
|
|
beebcd5851 | ||
|
|
5a1493fb4a | ||
|
|
8fb16217f7 | ||
|
|
3b1f5de101 | ||
|
|
46d32b3553 | ||
|
|
e20e11e2d5 | ||
|
|
0b20df242c | ||
|
|
a16b6c19d4 | ||
|
|
9e558fd95e | ||
|
|
70be8bdaa7 | ||
|
|
847c3d47d0 | ||
|
|
1b768b14a7 | ||
|
|
3f6c02d71e | ||
|
|
98b9ec5185 | ||
|
|
0722c82359 | ||
|
|
1ae8d7a926 | ||
|
|
77459adc13 | ||
|
|
33a88f3d83 | ||
|
|
306756c63b | ||
|
|
af1c77fd54 | ||
|
|
73ce49c741 | ||
|
|
cdd52a3ddb | ||
|
|
172e7bed9c | ||
|
|
e2d712a26c | ||
|
|
a8ce04d3f8 | ||
|
|
eda1cbd125 |
@@ -1,5 +1,98 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
+5
-2
@@ -2,8 +2,11 @@ services:
|
|||||||
bot:
|
bot:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
MESH_HOST: 192.168.1.4
|
TZ: Europe/Warsaw
|
||||||
MESH_PORT: '4403'
|
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_DB_PATH: ./data/mesh-bot.db
|
||||||
MESH_POLL_MS: '1000'
|
MESH_POLL_MS: '1000'
|
||||||
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
||||||
|
|||||||
Generated
+123
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.11.3",
|
"version": "0.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.11.3",
|
"version": "0.19.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/swagger": "^12.0.2",
|
"@nestjs/swagger": "^12.0.2",
|
||||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||||
|
"fast-xml-parser": "^5.11.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -1148,6 +1149,18 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.151.0",
|
"version": "0.151.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
||||||
@@ -2336,6 +2349,18 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -3279,6 +3304,45 @@
|
|||||||
"fast-string-width": "^3.0.2"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3830,6 +3894,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4848,6 +4924,21 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -5503,6 +5594,21 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/strtok3": {
|
||||||
"version": "10.3.5",
|
"version": "10.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||||
@@ -6147,6 +6253,21 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yoctocolors": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.19.0",
|
"version": "0.22.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/swagger": "^12.0.2",
|
"@nestjs/swagger": "^12.0.2",
|
||||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||||
|
"fast-xml-parser": "^5.11.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BatteryAlertModule } from './battery/battery.module.js';
|
|||||||
import { CommandModule } from './commands/command.module.js';
|
import { CommandModule } from './commands/command.module.js';
|
||||||
import { LlmModule } from './llm/llm.module.js';
|
import { LlmModule } from './llm/llm.module.js';
|
||||||
import { MeshModule } from './mesh/mesh.module.js';
|
import { MeshModule } from './mesh/mesh.module.js';
|
||||||
|
import { RssModule } from './rss/rss.module.js';
|
||||||
import { WeatherModule } from './weather/weather.module.js';
|
import { WeatherModule } from './weather/weather.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -12,6 +13,7 @@ import { WeatherModule } from './weather/weather.module.js';
|
|||||||
BatteryAlertModule,
|
BatteryAlertModule,
|
||||||
CommandModule,
|
CommandModule,
|
||||||
LlmModule,
|
LlmModule,
|
||||||
|
RssModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
|
||||||
import { MeshModule } from '../mesh/mesh.module.js';
|
import { MeshModule } from '../mesh/mesh.module.js';
|
||||||
import { BatteryAlertService } from './battery.service.js';
|
import { BatteryAlertService } from './battery.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), MeshModule],
|
imports: [MeshModule],
|
||||||
providers: [BatteryAlertService],
|
providers: [BatteryAlertService],
|
||||||
exports: [BatteryAlertService],
|
exports: [BatteryAlertService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { fromBinary } from '@bufbuild/protobuf';
|
|
||||||
import * as P from '@meshtastic/protobufs';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js';
|
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||||
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
|
|
||||||
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
|
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
|
||||||
import { MeshService as RealMeshService } 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 {
|
import {
|
||||||
BatteryAlertService,
|
BatteryAlertService,
|
||||||
formatAlert,
|
formatAlert,
|
||||||
@@ -119,6 +117,47 @@ describe('BatteryAlertService check threshold', () => {
|
|||||||
expect(sendText).not.toHaveBeenCalled();
|
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 () => {
|
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
|
||||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||||
const svc = makeService(
|
const svc = makeService(
|
||||||
@@ -225,63 +264,38 @@ describe('BatteryAlertService check cooldown', () => {
|
|||||||
|
|
||||||
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
||||||
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
||||||
const bridge = await startBridge();
|
const client = new FakeMeshMonitorClient({
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
connected: true,
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
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
|
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
|
||||||
const mesh = new RealMeshService();
|
const mesh = new RealMeshService(undefined, undefined, client);
|
||||||
try {
|
try {
|
||||||
mesh.onModuleInit();
|
mesh.onModuleInit();
|
||||||
await waitFor(
|
await mesh.pollStatus();
|
||||||
() => bridge.sockets.length === 1 && mesh.status().connected,
|
|
||||||
);
|
|
||||||
|
|
||||||
bridge.sockets[0].write(
|
|
||||||
nodeInfoFrame(0xabc, {
|
|
||||||
user: { id: '!a', longName: 'Alpha' },
|
|
||||||
deviceMetrics: { batteryLevel: 5 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await waitFor(async () => (await mesh.nodes()).length === 1);
|
|
||||||
|
|
||||||
const out: Uint8Array[] = [];
|
|
||||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
|
||||||
const svc = new BatteryAlertService(mesh);
|
const svc = new BatteryAlertService(mesh);
|
||||||
await svc.check();
|
await svc.check();
|
||||||
|
|
||||||
const readOutbound = (): P.Mesh.MeshPacket[] => {
|
// one broadcast on the alert channel ("testowy", index 2) — not the pinned send channel (3)
|
||||||
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
expect(client.sends).toHaveLength(1);
|
||||||
let off = 0;
|
expect(client.sends[0]).toMatchObject({
|
||||||
for (const c of out) {
|
text: '⚠️ battery low: @!00000abc (Alpha) 5%',
|
||||||
merged.set(c, off);
|
toNodeId: undefined, // broadcast — not a DM
|
||||||
off += c.length;
|
channel: 2,
|
||||||
}
|
replyId: undefined,
|
||||||
const { packets } = extractFrames(merged);
|
|
||||||
// outbound starts with the want_config_id frame — collect only text packets, in order
|
|
||||||
return packets.flatMap((p) => {
|
|
||||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
|
||||||
if (tr.payloadVariant?.case === 'packet')
|
|
||||||
return [tr.payloadVariant.value];
|
|
||||||
return [];
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
await waitFor(() => readOutbound().length === 1);
|
|
||||||
const pkt = readOutbound()[0];
|
|
||||||
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
|
|
||||||
const data =
|
|
||||||
pkt.payloadVariant?.case === 'decoded'
|
|
||||||
? pkt.payloadVariant.value
|
|
||||||
: undefined;
|
|
||||||
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
|
|
||||||
'⚠️ battery low: @!00000abc (Alpha) 5%',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.MESH_HOST;
|
delete process.env.MESHMONITOR_API_BASE;
|
||||||
delete process.env.MESH_PORT;
|
|
||||||
delete process.env.MESH_SEND_CHANNEL;
|
delete process.env.MESH_SEND_CHANNEL;
|
||||||
mesh.onModuleDestroy();
|
mesh.onModuleDestroy();
|
||||||
bridge.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class BatteryAlertService {
|
|||||||
|
|
||||||
private shouldAlert(node: NodeSummary, now: number): boolean {
|
private shouldAlert(node: NodeSummary, now: number): boolean {
|
||||||
if (node.batteryLevel === undefined) return false; // no battery reported — skip
|
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)
|
// <= 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;
|
if (!(node.batteryLevel <= this.threshold)) return false;
|
||||||
const last = this.lastAlertAt.get(node.num);
|
const last = this.lastAlertAt.get(node.num);
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MeshModule } from '../mesh/mesh.module.js';
|
import { MeshModule } from '../mesh/mesh.module.js';
|
||||||
import { MeshService } from '../mesh/mesh.service.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';
|
import { CommandService } from './command.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MeshModule], // one-way only — the reverse edge is wired in the factory below, not via DI
|
imports: [MeshModule, RssModule], // one-way only — the MeshService reverse edge is wired in the factory below, not via DI
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: CommandService,
|
provide: CommandService,
|
||||||
useFactory: (mesh: MeshService) => {
|
useFactory: (mesh: MeshService, rss: RssService) => {
|
||||||
const commands = new CommandService(mesh);
|
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
|
// 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);
|
mesh.attachCommands(commands);
|
||||||
return commands;
|
return commands;
|
||||||
},
|
},
|
||||||
inject: [MeshService],
|
inject: [MeshService, RssService],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [CommandService],
|
exports: [CommandService],
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||||
import {
|
import type {
|
||||||
startBridge,
|
MessageRecord,
|
||||||
textPacketFrame,
|
MeshService,
|
||||||
waitFor,
|
NodeSummary,
|
||||||
outboundPackets,
|
} from '../mesh/mesh.service.js';
|
||||||
KAMIL_NUM,
|
|
||||||
} from '../mesh/fake-bridge.js';
|
|
||||||
import type { MeshService } from '../mesh/mesh.service.js';
|
|
||||||
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
import { 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';
|
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(
|
function makeService(
|
||||||
sendText?: ReturnType<typeof vi.fn>,
|
sendText?: ReturnType<typeof vi.fn>,
|
||||||
respond?: ReturnType<typeof vi.fn>,
|
respond?: ReturnType<typeof vi.fn>,
|
||||||
@@ -24,6 +32,7 @@ function makeService(
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
|
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', () => {
|
describe('CommandService.onRx', () => {
|
||||||
@@ -34,6 +43,16 @@ describe('CommandService.onRx', () => {
|
|||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
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 () => {
|
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
|
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
|
||||||
@@ -108,6 +127,61 @@ describe('CommandService.onRx', () => {
|
|||||||
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
|
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 () => {
|
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
svc.register('boom', () => {
|
svc.register('boom', () => {
|
||||||
@@ -134,117 +208,321 @@ describe('CommandService.onRx', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)', () => {
|
describe('MeshService wiring (event-driven trigger)', () => {
|
||||||
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
const MY_NUM = 3994646728;
|
||||||
const bridge = await startBridge();
|
const T0 = 1_760_000_000_000;
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
// wire the responder exactly like command.module.ts does in production,
|
||||||
const svc = new RealMeshService();
|
// against the fake gateway client; `settle` lets the fire-and-forget
|
||||||
try {
|
// onRx → respond → sendText microtask chain finish
|
||||||
// wire the responder exactly like command.module.ts does in production
|
const makeWiredService = async (
|
||||||
|
client: FakeMeshMonitorClient,
|
||||||
|
): Promise<RealMeshService> => {
|
||||||
|
const svc = new RealMeshService(undefined, undefined, client);
|
||||||
svc.attachCommands(new CommandService(svc));
|
svc.attachCommands(new CommandService(svc));
|
||||||
svc.onModuleInit();
|
svc.onModuleInit();
|
||||||
await waitFor(
|
await svc.pollStatus();
|
||||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
return svc;
|
||||||
);
|
};
|
||||||
|
|
||||||
const out: Uint8Array[] = [];
|
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
|
||||||
|
|
||||||
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
|
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
||||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
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();
|
||||||
|
|
||||||
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
// history: the reply is recorded as a DM (mesh channel 0 — the gateway reports DMs as -1)
|
||||||
await waitFor(() => svc.messages().length === 2);
|
|
||||||
expect(svc.messages()[0]).toMatchObject({
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to: KAMIL_NUM,
|
to: KAMIL_NUM,
|
||||||
text: '#pong!',
|
text: '#pong!',
|
||||||
channel: 2,
|
channel: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
|
// client-level: a DM back to the sender (toNodeId), quoting the ping packet (threaded reply)
|
||||||
await waitFor(() => outboundPackets(out).length >= 1);
|
expect(client.sends).toHaveLength(1);
|
||||||
const pkt = outboundPackets(out)[0]!;
|
expect(client.sends[0]).toMatchObject({
|
||||||
expect(pkt.to).toBe(KAMIL_NUM);
|
text: '#pong!',
|
||||||
expect(pkt.channel).toBe(2);
|
toNodeId: KAMIL_ID,
|
||||||
const data =
|
channel: undefined,
|
||||||
pkt.payloadVariant?.case === 'decoded'
|
replyId: 7,
|
||||||
? pkt.payloadVariant.value
|
});
|
||||||
: undefined;
|
|
||||||
expect(data?.replyId).toBe(1);
|
|
||||||
} finally {
|
} finally {
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
bridge.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
||||||
const bridge = await startBridge();
|
const client = new FakeMeshMonitorClient({
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
connected: true,
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
nodeNum: MY_NUM,
|
||||||
const svc = new RealMeshService();
|
});
|
||||||
|
const svc = await makeWiredService(client);
|
||||||
try {
|
try {
|
||||||
svc.attachCommands(new CommandService(svc));
|
// rx broadcast from KAMIL on channel 2 (packet id 7)
|
||||||
svc.onModuleInit();
|
client.pushInbound({
|
||||||
await waitFor(
|
packetId: 7,
|
||||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
fromNum: KAMIL_NUM,
|
||||||
);
|
fromId: KAMIL_ID,
|
||||||
|
toId: 'broadcast',
|
||||||
|
text: '#ping',
|
||||||
|
channel: 2,
|
||||||
|
portnum: 1,
|
||||||
|
at: T0,
|
||||||
|
});
|
||||||
|
await svc.pollMessages();
|
||||||
|
await settle();
|
||||||
|
|
||||||
const out: Uint8Array[] = [];
|
// broadcast original → broadcast reply, on the same channel
|
||||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
|
||||||
|
|
||||||
// rx broadcast from KAMIL on channel 2 (packet id 1)
|
|
||||||
bridge.sockets[0].write(
|
|
||||||
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => svc.messages().length === 2);
|
|
||||||
expect(svc.messages()[0]).toMatchObject({
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
to: BROADCAST_ADDR,
|
||||||
text: '#pong!',
|
text: '#pong!',
|
||||||
channel: 2,
|
channel: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => outboundPackets(out).length >= 1);
|
expect(client.sends).toHaveLength(1);
|
||||||
const pkt = outboundPackets(out)[0]!;
|
expect(client.sends[0]).toMatchObject({
|
||||||
expect(pkt.to).toBe(BROADCAST_ADDR);
|
text: '#pong!',
|
||||||
expect(pkt.channel).toBe(2);
|
toNodeId: undefined,
|
||||||
const data =
|
channel: 2,
|
||||||
pkt.payloadVariant?.case === 'decoded'
|
replyId: 7, // threaded quote of the ping packet (id 7)
|
||||||
? pkt.payloadVariant.value
|
});
|
||||||
: undefined;
|
|
||||||
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
|
|
||||||
} finally {
|
} finally {
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
bridge.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not answer #ping arriving on a non-command channel', async () => {
|
it('does not answer #ping arriving on a non-command channel', async () => {
|
||||||
const bridge = await startBridge();
|
const client = new FakeMeshMonitorClient({
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
connected: true,
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
nodeNum: MY_NUM,
|
||||||
const svc = new RealMeshService();
|
});
|
||||||
|
const svc = await makeWiredService(client);
|
||||||
try {
|
try {
|
||||||
svc.attachCommands(new CommandService(svc));
|
client.pushInbound({
|
||||||
svc.onModuleInit();
|
packetId: 7,
|
||||||
await waitFor(
|
fromNum: KAMIL_NUM,
|
||||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
fromId: KAMIL_ID,
|
||||||
);
|
toNum: MY_NUM,
|
||||||
|
text: '#ping',
|
||||||
|
channel: 3, // not the command channel (2)
|
||||||
|
portnum: 1,
|
||||||
|
at: T0,
|
||||||
|
});
|
||||||
|
await svc.pollMessages();
|
||||||
|
await settle();
|
||||||
|
|
||||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
|
// only the rx record — no reply anywhere
|
||||||
|
|
||||||
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
|
|
||||||
expect(svc.messages()[0]).toMatchObject({
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
dir: 'rx',
|
dir: 'rx',
|
||||||
text: '#ping',
|
text: '#ping',
|
||||||
channel: 3,
|
channel: 3,
|
||||||
});
|
});
|
||||||
|
expect(client.sends).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
bridge.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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';
|
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||||
// type-only: the instance comes from DI (command.module.ts factory) or direct construction in tests — no runtime import cycle with src/mesh
|
// 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 { 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. */
|
/** Context handed to a command handler: the trimmed text plus sender/packet id when present. */
|
||||||
export interface CommandContext {
|
export interface CommandContext {
|
||||||
@@ -13,10 +16,10 @@ export interface CommandContext {
|
|||||||
id?: number;
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A command handler: returns the reply text to send back, or void/undefined for no reply. */
|
/** 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 = (
|
export type CommandHandler = (
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) => string | undefined | Promise<string | undefined>;
|
) => string | string[] | undefined | Promise<string | string[] | undefined>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandService {
|
export class CommandService {
|
||||||
@@ -26,12 +29,82 @@ export class CommandService {
|
|||||||
process.env.COMMAND_CHANNEL ?? '2',
|
process.env.COMMAND_CHANNEL ?? '2',
|
||||||
10,
|
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())
|
// 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>();
|
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. */
|
/** mesh is optional so tests can construct without a bridge; production DI always passes one. */
|
||||||
constructor(private readonly mesh?: MeshService) {
|
constructor(
|
||||||
|
private readonly mesh?: MeshService,
|
||||||
|
rss?: RssService,
|
||||||
|
) {
|
||||||
this.register('ping', () => '#pong!');
|
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 a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
|
||||||
@@ -39,9 +112,14 @@ export class CommandService {
|
|||||||
this.commands.set(name, fn);
|
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.
|
* Event-driven entry point called by MeshService for every decoded rx text.
|
||||||
* No-op unless on COMMAND_CHANNEL with an exact `#command` match (case-sensitive).
|
* 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
|
* 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
|
* 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
|
* id does it fall back to legacy sendText routing on COMMAND_CHANNEL. Never throws — a failed
|
||||||
@@ -56,23 +134,43 @@ export class CommandService {
|
|||||||
if (channel !== this.commandChannel) return;
|
if (channel !== this.commandChannel) return;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers
|
if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers
|
||||||
const handler = this.commands.get(trimmed.slice(1));
|
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
|
if (!handler) return; // exact, case-sensitive match — no partials
|
||||||
try {
|
try {
|
||||||
const reply = await handler({ text: trimmed, from, id });
|
const reply = await handler({ text: trimmed, from, id });
|
||||||
if (reply === undefined || reply === null) return; // void = no reply
|
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) {
|
if (id !== undefined && this.mesh) {
|
||||||
// respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote)
|
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
|
||||||
await this.mesh.respond(id, reply);
|
await this.mesh.respond(id, line);
|
||||||
} else {
|
} else {
|
||||||
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
||||||
await this.mesh?.sendText(
|
await this.mesh?.sendText(
|
||||||
from ?? BROADCAST_ADDR,
|
from ?? BROADCAST_ADDR,
|
||||||
reply,
|
line,
|
||||||
undefined,
|
undefined,
|
||||||
this.commandChannel,
|
this.commandChannel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export class LlmService {
|
|||||||
private readonly apiBase =
|
private readonly apiBase =
|
||||||
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
||||||
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
||||||
// local 27B model can be slow — generous default
|
// local 27B model can be slow (uncontrollable reasoning tokens) — generous default: 3 min headroom for slow reasoning periods
|
||||||
private readonly timeoutMs = parseInt(
|
private readonly timeoutMs = parseInt(
|
||||||
process.env.LLM_TIMEOUT_MS ?? '60000',
|
process.env.LLM_TIMEOUT_MS ?? '180000',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
|
||||||
import * as P from '@meshtastic/protobufs';
|
|
||||||
import * as net from 'node:net';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { extractFrames, 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 },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
|
|
||||||
export function textPacketFrame(
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
text: string,
|
|
||||||
channel = 0,
|
|
||||||
): Uint8Array {
|
|
||||||
const data = create(P.Mesh.DataSchema, {
|
|
||||||
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
|
||||||
payload: new TextEncoder().encode(text),
|
|
||||||
});
|
|
||||||
const pkt = create(P.Mesh.MeshPacketSchema, {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
id: 1,
|
|
||||||
channel,
|
|
||||||
payloadVariant: { case: 'decoded', value: data },
|
|
||||||
});
|
|
||||||
return frame(
|
|
||||||
toBinary(
|
|
||||||
P.Mesh.FromRadioSchema,
|
|
||||||
create(P.Mesh.FromRadioSchema, {
|
|
||||||
payloadVariant: { case: 'packet', value: pkt },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
|
|
||||||
export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
|
|
||||||
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
|
||||||
let off = 0;
|
|
||||||
for (const c of out) {
|
|
||||||
merged.set(c, off);
|
|
||||||
off += c.length;
|
|
||||||
}
|
|
||||||
return extractFrames(merged).packets.flatMap((p) => {
|
|
||||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
|
||||||
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+102
-64
@@ -1,19 +1,12 @@
|
|||||||
import { fromBinary } from '@bufbuild/protobuf';
|
|
||||||
import * as P from '@meshtastic/protobufs';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||||
import type * as net from 'node:net';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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 {
|
import {
|
||||||
KAMIL_NUM,
|
nodeNumToId,
|
||||||
kamilNodeInfo,
|
type MMMessage,
|
||||||
nodeInfoFrame,
|
} from './meshmonitor/mesh-monitor-client.js';
|
||||||
startBridge,
|
|
||||||
telemetryPacketFrame,
|
|
||||||
textPacketFrame,
|
|
||||||
waitFor,
|
|
||||||
} from './fake-bridge.js';
|
|
||||||
import { createMeshMcpServer } from './mcp.controller.js';
|
import { createMeshMcpServer } from './mcp.controller.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
@@ -26,23 +19,63 @@ function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
|
|||||||
return c.text ?? '';
|
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<{
|
async function connectedService(): Promise<{
|
||||||
svc: MeshService;
|
svc: MeshService;
|
||||||
sockets: net.Socket[];
|
client: FakeMeshMonitorClient;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
}> {
|
}> {
|
||||||
const bridge = await startBridge();
|
// pin the gateway base so the status() host/port assertion is deterministic
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
process.env.MESHMONITOR_API_BASE = 'https://meshmonitor.kkhost.pl/api/v1';
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
const client = makeClient();
|
||||||
const svc = new MeshService();
|
const svc = new MeshService(undefined, undefined, client);
|
||||||
svc.onModuleInit();
|
svc.onModuleInit();
|
||||||
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
|
await svc.pollStatus();
|
||||||
return {
|
return {
|
||||||
svc,
|
svc,
|
||||||
sockets: bridge.sockets,
|
client,
|
||||||
close: () => {
|
close: () => {
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
bridge.close();
|
delete process.env.MESHMONITOR_API_BASE;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,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();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
@@ -129,9 +162,9 @@ describe('MCP server tools', () => {
|
|||||||
expect(r.isError).not.toBe(true);
|
expect(r.isError).not.toBe(true);
|
||||||
expect(JSON.parse(toolText(r))).toEqual({
|
expect(JSON.parse(toolText(r))).toEqual({
|
||||||
connected: true,
|
connected: true,
|
||||||
myNodeNum: null,
|
myNodeNum: MY_NUM,
|
||||||
host: '127.0.0.1',
|
host: 'meshmonitor.kkhost.pl',
|
||||||
port: Number(process.env.MESH_PORT),
|
port: 443,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.close();
|
await mcp.close();
|
||||||
@@ -144,8 +177,8 @@ describe('MCP server tools', () => {
|
|||||||
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
env.client.setNodes([kamilNode()]);
|
||||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
await env.svc.pollNodes();
|
||||||
|
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
try {
|
||||||
@@ -160,6 +193,7 @@ describe('MCP server tools', () => {
|
|||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to: BROADCAST_ADDR,
|
to: BROADCAST_ADDR,
|
||||||
});
|
});
|
||||||
|
expect(env.client.sends[0]?.channel).toBe(3); // pinned test channel
|
||||||
|
|
||||||
// name lookup via the node DB (unique substring)
|
// name lookup via the node DB (unique substring)
|
||||||
const r2 = await mcp.client.callTool({
|
const r2 = await mcp.client.callTool({
|
||||||
@@ -171,6 +205,7 @@ describe('MCP server tools', () => {
|
|||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to: KAMIL_NUM,
|
to: KAMIL_NUM,
|
||||||
});
|
});
|
||||||
|
expect(env.client.sends[1]?.toNodeId).toBe(KAMIL_ID); // a gateway DM
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.close();
|
await mcp.close();
|
||||||
}
|
}
|
||||||
@@ -209,8 +244,10 @@ describe('MCP server tools', () => {
|
|||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
// rx DM from KAMIL on channel 3 (packet id 1)
|
// rx DM from KAMIL on channel 3 (packet id 1)
|
||||||
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
env.client.pushInbound(
|
||||||
await waitFor(() => env.svc.messages().length === 1);
|
inbound({ text: 'ping', packetId: 1, channel: 3 }),
|
||||||
|
);
|
||||||
|
await env.svc.pollMessages();
|
||||||
|
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
try {
|
||||||
@@ -221,12 +258,17 @@ describe('MCP server tools', () => {
|
|||||||
expect(r1.isError).not.toBe(true);
|
expect(r1.isError).not.toBe(true);
|
||||||
const reply = JSON.parse(toolText(r1)) as { id: number };
|
const reply = JSON.parse(toolText(r1)) as { id: number };
|
||||||
expect(reply.id).toBeGreaterThan(0);
|
expect(reply.id).toBeGreaterThan(0);
|
||||||
// DM back to the original sender on the original channel
|
// 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({
|
expect(env.svc.messages()[0]).toMatchObject({
|
||||||
dir: 'tx',
|
dir: 'tx',
|
||||||
to: KAMIL_NUM,
|
to: KAMIL_NUM,
|
||||||
text: 'pong',
|
text: 'pong',
|
||||||
channel: 3,
|
channel: 0,
|
||||||
|
});
|
||||||
|
expect(env.client.sends[0]).toMatchObject({
|
||||||
|
toNodeId: KAMIL_ID,
|
||||||
|
replyId: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const r2 = await mcp.client.callTool({
|
const r2 = await mcp.client.callTool({
|
||||||
@@ -256,16 +298,11 @@ describe('MCP server tools', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inject_message records an rx entry without sending over RF', async () => {
|
it('inject_message records an rx entry without sending over the air', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
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({
|
const r1 = await mcp.client.callTool({
|
||||||
name: 'inject_message',
|
name: 'inject_message',
|
||||||
arguments: { text: 'trigger' },
|
arguments: { text: 'trigger' },
|
||||||
@@ -297,19 +334,8 @@ describe('MCP server tools', () => {
|
|||||||
expect(r3.isError).toBe(true);
|
expect(r3.isError).toBe(true);
|
||||||
expect(toolText(r3)).toContain('invalid node number');
|
expect(toolText(r3)).toContain('invalid node number');
|
||||||
|
|
||||||
// nothing went over the wire — synthetic inbox injection only.
|
// nothing was transmitted — synthetic inbox injection only
|
||||||
// The want_config frame (written on connect) may or may not have been captured;
|
expect(env.client.sends).toHaveLength(0);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.close();
|
await mcp.close();
|
||||||
}
|
}
|
||||||
@@ -321,8 +347,8 @@ describe('MCP server tools', () => {
|
|||||||
it('get_node resolves by name and errors when missing', async () => {
|
it('get_node resolves by name and errors when missing', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
env.client.setNodes([kamilNode()]);
|
||||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
await env.svc.pollNodes();
|
||||||
|
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
try {
|
||||||
@@ -354,8 +380,19 @@ describe('MCP server tools', () => {
|
|||||||
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
|
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
env.client.setChannels([{ index: 3, name: 'test' }]);
|
||||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
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);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
try {
|
||||||
@@ -368,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(
|
expect(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
toolText(
|
toolText(
|
||||||
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
|
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
|
).toEqual({ myNodeNum: MY_NUM, localConfig: {}, moduleConfig: {} });
|
||||||
|
|
||||||
const nodes = JSON.parse(
|
const nodes = JSON.parse(
|
||||||
toolText(
|
toolText(
|
||||||
@@ -399,13 +444,6 @@ describe('MCP server tools', () => {
|
|||||||
) as { dir: string; to?: number }[];
|
) as { dir: string; to?: number }[];
|
||||||
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
|
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(
|
const tel = JSON.parse(
|
||||||
toolText(
|
toolText(
|
||||||
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
|
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
|
||||||
@@ -416,7 +454,7 @@ describe('MCP server tools', () => {
|
|||||||
from: KAMIL_NUM,
|
from: KAMIL_NUM,
|
||||||
kind: 'deviceMetrics',
|
kind: 'deviceMetrics',
|
||||||
});
|
});
|
||||||
expect(tel[0].data).toEqual({ batteryLevel: 87 });
|
expect(tel[0].data).toEqual({ batteryLevel: { value: 87 } });
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.close();
|
await mcp.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class MeshController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Bridge connection status',
|
summary: 'Bridge connection status',
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -77,8 +77,8 @@ export class MeshController {
|
|||||||
example: {
|
example: {
|
||||||
connected: true,
|
connected: true,
|
||||||
myNodeNum: 123456789,
|
myNodeNum: 123456789,
|
||||||
host: '192.168.1.4',
|
host: 'meshmonitor.kkhost.pl',
|
||||||
port: 4403,
|
port: 443,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
status() {
|
status() {
|
||||||
@@ -165,9 +165,9 @@ export class MeshController {
|
|||||||
|
|
||||||
@Get('device')
|
@Get('device')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Device info (metadata + config, keys redacted)',
|
summary: 'Device info (firmware; config sections degraded)',
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -175,33 +175,8 @@ export class MeshController {
|
|||||||
example: {
|
example: {
|
||||||
myNodeNum: 123456789,
|
myNodeNum: 123456789,
|
||||||
firmwareVersion: '2.7.0.1',
|
firmwareVersion: '2.7.0.1',
|
||||||
metadata: {
|
localConfig: {},
|
||||||
firmwareVersion: '2.7.0.1',
|
moduleConfig: {},
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
device(): DeviceInfo {
|
device(): DeviceInfo {
|
||||||
|
|||||||
+12
-3
@@ -5,6 +5,11 @@ import { DatabaseSync } from 'node:sqlite';
|
|||||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||||
import { McpController } from './mcp.controller.js';
|
import { McpController } from './mcp.controller.js';
|
||||||
import { MeshController } from './mesh.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';
|
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.
|
// SQLite-backed message history: file path from MESH_DB_PATH (default ./data/mesh-bot.db), parent dir created if missing.
|
||||||
@@ -17,14 +22,18 @@ function openMessageDb(): DatabaseSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MeshMonitorModule],
|
||||||
controllers: [MeshController, McpController],
|
controllers: [MeshController, McpController],
|
||||||
providers: [
|
providers: [
|
||||||
OpencodeService,
|
OpencodeService,
|
||||||
{
|
{
|
||||||
|
// the MeshMonitor client is null when MESHMONITOR_API_TOKEN is unset — the service then stays disconnected
|
||||||
provide: MeshService,
|
provide: MeshService,
|
||||||
useFactory: (opencode: OpencodeService) =>
|
useFactory: (
|
||||||
new MeshService(openMessageDb(), opencode),
|
opencode: OpencodeService,
|
||||||
inject: [OpencodeService],
|
client: MeshMonitorClient | null,
|
||||||
|
) => new MeshService(openMessageDb(), opencode, client),
|
||||||
|
inject: [OpencodeService, MESH_MONITOR_CLIENT],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MeshService],
|
exports: [MeshService],
|
||||||
|
|||||||
+1392
-1418
File diff suppressed because it is too large
Load Diff
+450
-337
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
const [url, init] = fetchMock.mock.calls[1];
|
const [url, init] = fetchMock.mock.calls[1];
|
||||||
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
|
expect(url).toBe(`${API}/proxy/abc/api/session/ses_pinned/prompt`);
|
||||||
const opts = init as {
|
const opts = init as {
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => {
|
|||||||
expect(opts.headers?.authorization).toBe(
|
expect(opts.headers?.authorization).toBe(
|
||||||
'Basic ' + Buffer.from('u:p').toString('base64'),
|
'Basic ' + Buffer.from('u:p').toString('base64'),
|
||||||
);
|
);
|
||||||
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
|
// body is EXACTLY {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered
|
||||||
expect(String(opts.body)).toBe(
|
expect(String(opts.body)).toBe(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -111,12 +106,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
const [, init] = fetchMock.mock.calls[1];
|
const [, init] = fetchMock.mock.calls[1];
|
||||||
expect(String((init as { body?: string }).body)).toBe(
|
expect(String((init as { body?: string }).body)).toBe(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'New mesh message (id 7): "hi" — please react to it.',
|
text: 'New mesh message (id 7): "hi" — please react to it.',
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +172,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||||
`${API}/proxy/b/session/ses_pinned/message`,
|
`${API}/proxy/b/api/session/ses_pinned/prompt`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,18 +181,37 @@ describe('OpencodeService.notify', () => {
|
|||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
json([
|
// v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
|
||||||
{ id: 'ses_old', time: { updated: 100 } },
|
// directory, so the location.directory filter must exclude it
|
||||||
{ id: 'ses_new', time: { updated: 200 } },
|
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({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
// 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(
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_new/message`,
|
`${API}/proxy/abc/api/session/ses_new/prompt`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,13 +219,22 @@ describe('OpencodeService.notify', () => {
|
|||||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
.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({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
|
||||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_a/message`,
|
`${API}/proxy/abc/api/session/ses_a/prompt`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,11 +250,11 @@ describe('OpencodeService.notify', () => {
|
|||||||
|
|
||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
// workspaces GET + message POST only — no /session discovery GET
|
// workspaces GET + prompt POST only — no /api/session discovery GET
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,7 +266,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
});
|
});
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
|
.mockRejectedValueOnce(timeoutError()); // the prompt POST times out — a read timeout is still treated as delivered (safety net)
|
||||||
|
|
||||||
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
||||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
|
|
||||||
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
|
// 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];
|
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
||||||
// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes
|
// 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;
|
const TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
||||||
@@ -14,9 +15,11 @@ interface WorkspaceEntry {
|
|||||||
proxyPath?: string;
|
proxyPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entry of GET {base}/session (only the fields we use). */
|
/** Entry of GET {base}/api/session (v2; only the fields we use — directory lives under location, time is epoch ms). */
|
||||||
interface SessionEntry {
|
interface SessionEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
location?: { directory?: string };
|
||||||
time?: { updated?: number };
|
time?: { updated?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +46,11 @@ export class OpencodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered —
|
* Push an rx mesh message into the opencode session (v2: POST {base}/api/session/{id}/prompt with a
|
||||||
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
|
* `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means
|
||||||
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
|
* DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) and is never retried.
|
||||||
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
* Workspace/session resolution failures are retried up to RETRY_DELAYS_MS.length + 1 attempts total;
|
||||||
* after N attempts`. No-op when disabled (no credentials).
|
* 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> {
|
async notify(id: number, text: string, from?: number): Promise<void> {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
@@ -112,12 +115,19 @@ export class OpencodeService {
|
|||||||
const base = this.api + (ws.proxyPath ?? '');
|
const base = this.api + (ws.proxyPath ?? '');
|
||||||
let sid = this.sessionOverride;
|
let sid = this.sessionOverride;
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`);
|
// 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)
|
if (sessions.length === 0)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
|
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
|
||||||
);
|
);
|
||||||
// most recently updated session; first entry when none carry time.updated
|
// most recently updated session (epoch ms); first entry when none carry time.updated
|
||||||
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
|
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
|
||||||
sid = sessions.some((s) => updated(s))
|
sid = sessions.some((s) => updated(s))
|
||||||
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
||||||
@@ -148,16 +158,17 @@ export class OpencodeService {
|
|||||||
msg: string,
|
msg: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/session/${sid}/message`, {
|
const res = await fetch(`${base}/api/session/${sid}/prompt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
|
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }),
|
body: JSON.stringify({ text: msg }),
|
||||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
|
// a read timeout here means DELIVERED, not failed — kept as a harmless safety net: v2 normally
|
||||||
// the response until the agent turn finishes; never retried
|
// 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') {
|
if ((err as Error).name === 'TimeoutError') {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(',');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user