Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b20df242c | ||
|
|
a16b6c19d4 | ||
|
|
9e558fd95e | ||
|
|
70be8bdaa7 | ||
|
|
847c3d47d0 | ||
|
|
1b768b14a7 | ||
|
|
3f6c02d71e | ||
|
|
98b9ec5185 | ||
|
|
0722c82359 | ||
|
|
1ae8d7a926 | ||
|
|
77459adc13 | ||
|
|
33a88f3d83 | ||
|
|
306756c63b | ||
|
|
af1c77fd54 | ||
|
|
73ce49c741 | ||
|
|
cdd52a3ddb | ||
|
|
172e7bed9c | ||
|
|
e2d712a26c | ||
|
|
a8ce04d3f8 | ||
|
|
eda1cbd125 | ||
|
|
023cd3eef3 | ||
|
|
c5a2815c01 | ||
|
|
55e59d4963 | ||
|
|
9d4c9594af | ||
|
|
d375f2a943 | ||
|
|
931b42bb1e | ||
|
|
2b2e494023 | ||
|
|
4ae932befe | ||
|
|
ff57f3eae1 | ||
|
|
b7b2044d84 | ||
|
|
e62f831ee5 | ||
|
|
bd18bdfd8a | ||
|
|
c33f4613b5 | ||
|
|
88b617f8b7 | ||
|
|
34e7bfe0bb | ||
|
|
b677c05681 | ||
|
|
83e47c9609 | ||
|
|
5eb1a79e70 | ||
|
|
7d7c569d08 | ||
|
|
c0c069ef08 | ||
|
|
1b712f7eee | ||
|
|
6f6b704dcf | ||
|
|
fe8d2dbe0b | ||
|
|
d0b71e6e91 | ||
|
|
8ddd91d4b5 | ||
|
|
6ce0b1ae7b | ||
|
|
15d93ec2a4 | ||
|
|
0909fbc6e2 | ||
|
|
b8ae5d39f7 | ||
|
|
5999159e17 | ||
|
|
36021ec2e8 | ||
|
|
dca0dfa894 | ||
|
|
5e03546f40 | ||
|
|
261f7ed18c |
+188
@@ -1,5 +1,193 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.7 — 2026-09-27 06:50
|
||||
|
||||
- Fixed double registration of scheduled jobs: `ScheduleModule.forRoot()` was called from both `WeatherModule` and `BatteryAlertModule`, so Nest created two ScheduleExplorers, each scanning all providers — every @Cron/@Interval job (hourly weather broadcast, per-minute battery check) fired twice. Removed the call from `battery.module.ts`; the single registration in `weather.module.ts` remains.
|
||||
- Tests: 174 → 175 — new regression spec (`src/schedule-singleton.spec.ts`) asserts `ScheduleModule.forRoot(` appears exactly once across all `src/**/*.module.ts`.
|
||||
|
||||
## v0.21.6 — 2026-09-26 10:37
|
||||
|
||||
- New `#help` command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — `#ping`, the `#rss help/add/remove/get/sources/tags` family. Registered in the CommandService constructor next to `#ping`.
|
||||
- Tests: 173 → 174 — `#help` dispatches through respond() exactly once with text containing both `#ping` and `#rss`. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.)
|
||||
|
||||
## v0.21.5 — 2026-09-26 10:26
|
||||
|
||||
- Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
|
||||
- Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin `MESH_MULTI_SEND_DELAY_MS=0` in `beforeEach` so they stay fast.
|
||||
|
||||
## v0.21.4 — 2026-09-26 09:51
|
||||
|
||||
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
|
||||
|
||||
## v0.21.3 — 2026-09-26 09:31
|
||||
|
||||
- Idle timeout default lowered from 10 min to **1 min** (`MESH_IDLE_TIMEOUT_MS` fallback `600000` → `60000`) — the v0.21.2 frame-based watchdog works in production (it fired today and the reconnect delivered queued packets), but the bridge/radio side at 192.168.1.4 keeps killing inbound RX within ~20 min of a fresh sync, so a shorter detection window is wanted.
|
||||
- New **lazy periodic autoreconnect**: `MESH_RECONNECT_INTERVAL_MS` (new env, default **300000** = 5 min, read at construction) — an interval that unconditionally logs ("periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed") and destroys the socket so the existing close→scheduleReconnect loop re-establishes with a fresh sync on schedule. When not connected at tick time, destroy is a no-op and the retry loop owns recovery (no special-casing). Started in `onModuleInit`, cleared in `onModuleDestroy`.
|
||||
- Tests: 171 → 172 — periodic reconnect observed end-to-end with an overridden short interval (fake bridge disconnect + re-establish); existing watchdog tests pass unchanged, comments updated to the new 60 s default.
|
||||
|
||||
## v0.21.2 — 2026-09-26 08:47
|
||||
|
||||
- Idle watchdog now tracks DECODED FRAMES, not raw bytes (fixes a second production zombie that slipped past v0.21.1): text+telemetry RX was dead for >1 h while TX worked and the watchdog never fired — /proc/net/snmp showed InSegs delta = 2 segments per 30 s, i.e. the bridge peer keeps dribbling small NON-frame bytes (~every 15 s) that kept resetting `lastPacketAt` on every socket chunk even though zero valid Meshtastic frames decoded. The `'data'` handler no longer updates `lastPacketAt` per chunk; it is updated only when `extractFrames` yields ≥1 valid packet (the reset in the `'connect'` handler is unchanged), so a stream that decodes nothing trips the watchdog after `MESH_IDLE_TIMEOUT_MS` even while raw bytes keep arriving.
|
||||
- Buffer-overflow backstop: if the undecoded frame buffer exceeds `MESH_MAX_FRAME_BUFFER` (new env, default **256000** = 250 KB) the service logs ERROR ("frame buffer overflow — stream desync, forcing reconnect") and destroys the socket → existing close→scheduleReconnect self-heals a desynced stream. The undecoded buffer is also reset on every (re)connect so leftover garbage from a dead session can't re-trigger the backstop or corrupt the first frames of a healthy new stream.
|
||||
- Tests: 169 → 171 — raw non-frame bytes do NOT keep the watchdog alive (garbage-only stream forces disconnect + reconnect, exactly today's incident shape), and an undecoded chunk over `MESH_MAX_FRAME_BUFFER` triggers destroy + a fresh session that decodes normally. The existing valid-frames-reset-the-clock test now exercises frame-based tracking unchanged.
|
||||
|
||||
## v0.21.1 — 2026-09-26 06:47
|
||||
|
||||
- Bridge zombie-connection fix (production incident: outbound sends kept working ~10 h while inbound packets stopped entirely and `/api/status` stayed `connected:true`). New **idle watchdog**: the service tracks `lastPacketAt`, updated on EVERY chunk received from the bridge in the lowest-level socket `'data'` path (any frame kind, decodable or not) and reset when a connection successfully starts. A 30 s interval checks: if connected AND no inbound traffic for `MESH_IDLE_TIMEOUT_MS` (new env, default **600000** = 10 min), it logs WARN ("no bridge traffic for X ms — forcing reconnect") and destroys the socket — the EXISTING connect/retry loop (`MESH_RECONNECT_MS`) then re-establishes with a fresh sync. No changes to connect-error retry/backoff behavior; this only adds detection for established-but-silent connections.
|
||||
- TCP keepalive on the bridge socket: `socket.setKeepAlive(true, 30_000)` — cheap insurance against dead peers at the TCP level, complementing the application-level watchdog.
|
||||
- Tests: 165 → 169 — silence past an overridden timeout forces disconnect + reconnect (fake bridge), inbound traffic keeps resetting the idle clock so no false trigger, default timeout does not fire in a test window, and `setKeepAlive(true, 30_000)` is asserted on the socket.
|
||||
|
||||
## v0.21.0 — 2026-09-25 19:59
|
||||
|
||||
- `#rss get <name|#tag>` now replies with **3–5 SEPARATE mesh messages** instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet.
|
||||
- New summarizer prompt asks for exactly 3-5 lines, one item per line, format `<short header>: <one or two short sentences>` with a ≤20-char header (e.g. `Culture: Museum opens new wing`), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1).
|
||||
- The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a `:` header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent.
|
||||
- Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (`fitOneLine`), so every item fits one packet even if the model ignores the prompt. `fitOnePacket()` (whole-reply ≤190) is gone — it no longer applies to joined text.
|
||||
- CommandService now sends array replies line by line: each element goes through the same routing as a single reply (`respond(id, line)` threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command.
|
||||
- LLM failure path unchanged: single `summary failed` reply via respond(). `#rss get #tag` gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged.
|
||||
- Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through `handle()`, direct `parseSummaryItems`/`fitOneLine` specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback).
|
||||
|
||||
## v0.20.1 — 2026-09-25 19:20
|
||||
|
||||
- `#rss get` summaries now fit ONE mesh packet (≤190 chars). Mesh fragments longer replies into fire-and-forget packets that recipients frequently never receive, and the old prompt ("a few short one-line highlights") produced ~326-334 char replies.
|
||||
- The summary prompt now asks for "at most 3 short bullet lines, each under 45 characters … bullets only - no intro, no other text" (verified against the live LLM: exactly 3 compliant bullets, ~110 chars). A `fitOnePacket()` backstop additionally cuts any over-length reply at complete-bullet boundaries so delivery is one packet even if the model ignores the prompt.
|
||||
- The summary `chat()` call now uses an explicit `maxTokens = 2500` (was 200). Empirically verified on this server: qwen3.8-27b emits `reasoning_content` and those tokens COUNT against max_tokens (cap 150 → completion_tokens 150, of which 105 were reasoning, finish_reason 'length'; a cap of 500 was fully consumed by reasoning with zero visible content). Thinking length for this task is unbounded and uncontrollable server-side (`/no_think`, `chat_template_kwargs.enable_thinking` and `thinking_budget` are all ignored) — observed 32..1776 tokens across runs. 2500 covers every observed run plus ~80 content tokens, and keeps the worst case (~42 s at ~60 tok/s) under the 60 s LLM timeout. The old cap of 200 guaranteed empty replies whenever reasoning exceeded it.
|
||||
- Tests: 154 → 158 — prompt shape + maxTokens asserted on the summary call, over-length reply cut through `handle()`, and direct `fitOnePacket` specs (pass-through at/under 190, bullet-boundary cut, hard-cut fallback for non-bulleted paragraphs).
|
||||
|
||||
## v0.20.0 — 2026-09-25 18:40
|
||||
|
||||
- New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add <url> <name>` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove <name>`, `#rss tags` (all distinct tags), `#rss sources`, `#rss get <name>` and `#rss get #<tag>` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`.
|
||||
- Storage: new `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file (default ./data/mesh-bot.db) via a second node:sqlite connection opened by `RssModule`'s factory — no new DB file. Names are matched case-insensitively everywhere (`COLLATE NOCASE`).
|
||||
- Feed parsing: new dependency `fast-xml-parser` (the only one added for this feature) — RSS 2.0 (`<item><title>/<link>`) and Atom (`<entry><title>`, link as `href` attribute); CDATA titles supported; malformed input or no usable items → "not a valid feed".
|
||||
- LLM: `RssService` injects `LlmService` (plain DI, one-way — `RssModule` imports `LlmModule`) and uses `chat()` directly with explicit maxTokens (50 for tags, 200 for summaries) to keep mesh replies short. Tag output is sanitized (lowercase, no #, whitespace → '-', deduped).
|
||||
- Dispatch: `CommandService` gains prefix registration (`registerPrefix(name, fn)`) alongside exact matches — one handler owns the whole `#rss` family while `#ping` stays an untouched exact match; exact names win over prefixes, and a name merely starting with the prefix (e.g. `#rssfoo`) does not dispatch. Wired in `CommandModule`'s factory (`inject: [MeshService, RssService]`) — still no constructor-level cycle, no forwardRef.
|
||||
- Tests: 128 → 153 specs — store CRUD against a real temp SQLite file (incl. second-connection persistence), feed parser fixtures (RSS 2.0 / Atom / malformed / empty titles), handler behavior with mocked LLM + fetch (help, add success/LLM-fail/invalid-url/duplicate/non-2xx/unparseable, remove found/missing, tags/sources empty+populated, get by name/by tag across sources/dead-feed resilience/unknown name+tag), and `#rss` prefix dispatch through respond().
|
||||
|
||||
## v0.19.0 — 2026-09-25 18:03
|
||||
|
||||
- New LLM client module (`src/llm`): `LlmService` speaks the OpenAI-compatible chat-completions API of the local server at `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`, no auth) using native fetch only — no new dependencies. Plain feature module (`LlmModule`), wired into `app.module.ts`; no Mesh/Schedule dependency.
|
||||
- Methods: `listModels()` (GET `/models` → `data[].id` strings), `chat(messages, opts?)` (POST `/chat/completions` with `{ model, messages, ...opts }` → `choices[0].message.content`; non-2xx throws an Error carrying the HTTP status and a short body snippet; malformed responses without content throw too), and `summarize(text)` — thin wrapper over `chat()` with system prompt "Summarize the following text concisely." (the stated primary use).
|
||||
- Env: `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`), `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default `60000` — the local 27B model can be slow; applied via `AbortSignal.timeout`).
|
||||
- Tests: +4 specs — listModels parses ids, chat passes model/messages/opts through and returns content, chat throws on non-2xx with status in the message, summarize sends system prompt + user text.
|
||||
|
||||
## v0.18.1 — 2026-09-25 17:30
|
||||
|
||||
- Command replies now ride `MeshService.respond(id, reply)` instead of direct `sendText` routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the **original message's channel** with `replyId = id`, so the recipient app renders it as a threaded quote of the triggering packet. `COMMAND_CHANNEL` is now only the trigger/listen gate — the reply channel comes from the record.
|
||||
- Defensive fallback: when the trigger has no packet id (never happens in production — `MeshService` always passes `pkt.id`), the legacy sendText routing on `COMMAND_CHANNEL` (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes.
|
||||
- Tests: 121 → 124 specs — unit cases now mock `mesh.respond` (id present → `respond(id, reply)` with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: `#ping` broadcast on ch2 → `#pong!` broadcast on ch2 with `replyId` = ping packet id, and `#ping` DM → `#pong!` DM to the sender on ch2 with `replyId` set. All gate tests (case/whitespace/channel/env override) kept.
|
||||
- The wire-decode test helper `outboundPackets()` moved from `mesh.service.spec.ts` into `fake-bridge.ts` so both specs share it.
|
||||
- Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (`COMMAND_CHANNEL` still exists, now meaning trigger channel only).
|
||||
|
||||
## v0.18.0 — 2026-09-25 17:08
|
||||
|
||||
- New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`.
|
||||
- Reply routing v1: directed DM back to the sender when present, else broadcast — always on `COMMAND_CHANNEL`, via the existing `MeshService.sendText(to, text, undefined, channel)` path. No threading/`replyId` in v1. A failing handler or send only logs a warn; it never crashes the app.
|
||||
- Wiring: `MeshService` gains an optional third constructor param (`commands?`) plus a public `attachCommands()` — the rx handler calls `this.commands?.onRx(channel, text, from, id)` right after `notifyRx()`, fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so `CommandModule` provides `CommandService` via a factory that injects `MeshService` and attaches itself as a side effect — no `forwardRef`, no module cycle.
|
||||
- Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, `COMMAND_CHANNEL` override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (`#ping` on ch2 answered with a directed `#pong!` tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances.
|
||||
- Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains `COMMAND_CHANNEL`.
|
||||
|
||||
## v0.17.0 — 2026-09-25 16:18
|
||||
|
||||
- The mesh→agent notify gate is now a comma-separated multi-channel list: `MESH_NOTIFY_CHANNEL` (default `3`) parses to channel indices, each entry trimmed, empty/invalid entries ignored (`"3,0"` → `[3, 0]`).
|
||||
- ch0 DM-only filtering: ch0 is the shared default channel where other users talk — only directed messages (both sender and recipient present) notify there; ch0 broadcasts and undirected traffic are ignored. Synthetic injections (`POST /api/messages`, `injectMessage`) ride ch0 by design and stay exempt from the rule, so they still notify when 0 is in the list.
|
||||
- `notifyRx()` gains optional params: `to?` (the packet's destination, now passed through from the rx handler) and an `injected` flag; the field is renamed `notifyChannel` → `notifyChannels`. Self-echo filter unchanged.
|
||||
- Tests: 102 → 106 specs — multi-list match/non-match, ch0 broadcast ignored even with 0 in the list, ch0 directed DM notified, ch0 injection notified via a multi-list, invalid env entries ignored.
|
||||
- Docs: AGENTS.md + MESHTASTIC.md "Notifications" describe the list semantics and the ch0 DM-only rule; README env bullet/forwarded/notifier lines and the controller's `GET /api/forwarded` description kept in sync. Local `.env` (gitignored) now `MESH_NOTIFY_CHANNEL=3,0`.
|
||||
|
||||
## v0.16.0 — 2026-09-25 15:49
|
||||
|
||||
- New `GET /api/forwarded`: the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]` — the id is the packet id (real rx) or generated record id (injected), `from` omitted when there is no sender. Backed by an in-memory ring buffer in `MeshService`, recorded in `notifyRx()` at hand-off: after the channel gate (`MESH_NOTIFY_CHANNEL`) and self-echo filter pass, right before the `OpencodeService.notify()` call; an unwired notifier records nothing (a notifier disabled internally still counts — MeshService cannot see its enabled flag). In-memory only, reset on restart. Purpose: a replying agent gets exactly the notified subset with packet ids instead of scanning all of `/api/messages`.
|
||||
- Tests: 96 → 102 specs — ring records on a gated forward (shape + newest-first via `forwarded()`), no record on channel mismatch / self-echo / unwired notifier, injected messages count when they pass the gate (`from` omitted when absent), cap at 100 with oldest evicted.
|
||||
- Docs: README endpoint bullet + curl example; AGENTS.md endpoints list kept in sync with the controller.
|
||||
|
||||
## v0.15.0 — 2026-09-25 15:40
|
||||
|
||||
- New `POST /api/respond` endpoint and MCP `respond(messageId, content)` tool: reply to an earlier message by packet id with automatic routing — the caller never thinks about where it goes. rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their own destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. Returns `{ id }`; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down; MCP tool errors carry the same messages.
|
||||
- Captured rx history records now persist their destination (`to: pkt.to`) — no schema change needed: the `to_node` column already existed for tx rows and flush/seed already round-tripped it, so captured packets simply fill it in too. This is what lets a reply tell a DM from a broadcast original.
|
||||
- The opencode notifier wrapper now includes the packet id: msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` — the id is the rx packet's id, or the generated record id for injected messages; the `from node` part is still omitted when absent.
|
||||
- Tests: 88 → 96 specs. New: captured-rx row persists `to_node`; six `respond` service specs (rx DM → directed send back to sender on the original channel with wire-level replyId, broadcast original → broadcast reply, tx record → its destination/channel, SQLite fallback when the record is outside the in-memory buffer, 404 memory+DB miss, 400 validation matrix); MCP `respond` wiring spec (routing + unknown-id/out-of-range/empty-content tool errors) and the tool list grows to ten.
|
||||
- Docs: README gains the endpoint bullet, a curl example, the MCP tool entry and the updated notifier wrapper format; AGENTS.md endpoints/MCP/notifier lines kept in sync with the controller.
|
||||
|
||||
## v0.14.0 — 2026-09-25 14:11
|
||||
|
||||
- The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service.
|
||||
- Ported forwarder behavior 1:1 with new env names: `OPENCODE_API` (default `http://172.24.0.1:9898` — opencode binds 0.0.0.0 on the host and is reachable from the bot container over dev_net; the old forwarder used 127.0.0.1 only because it ran with `network_mode: host`), `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), `OPENCODE_SESSION` (empty = discover the most recently updated session per request; non-empty = pin that session and skip discovery), `OPENCODE_USER` / `OPENCODE_PASS` (HTTP Basic auth on every call). Missing/empty credentials disable the notifier instead of failing: one warn at construction, `notify()` is a no-op — the app keeps running.
|
||||
- Workspace/session resolution per request (no caching — messages are rare): `GET {api}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH`, fallback name `mesh-bot`, status `ready` preferred when several match; then, unless pinned, `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry when none carry it). The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node <n>]: "<text>" — please react to it.` (em-dash, not a hyphen; the `from node` part omitted when there is no sender).
|
||||
- Timeout semantics preserved: every call has a 15 s timeout (`AbortSignal.timeout(15000)`); a read timeout on the final message POST means DELIVERED — opencode accepts immediately but holds the response until the agent turn finishes — so it is treated as success and never retried. Workspace/session GET failures are ordinary retryable failures: backoff [2 s, 10 s, 30 s] (the constant moved here from `mesh.service.ts`), up to 4 attempts total, final failure logs `opencode notify dropped after N attempts`.
|
||||
- `MeshService` loses `notifyUrl`, `postNotify()` and `NOTIFY_RETRY_DELAYS_MS`; the constructor gains an optional second parameter (`db?, opencode?`) so all existing direct constructions in specs keep working — without a notifier wired, `notifyRx()` is a no-op.
|
||||
- `docker-compose.yml`: forwarder service block deleted; bot environment drops `MESH_NOTIFY_URL`, keeps `MESH_NOTIFY_CHANNEL`, and passes the five `OPENCODE_*` vars via `${VAR:-default}` interpolation from `.env`. Local `.env` (gitignored) renamed `FORWARDER_USER/PASS/SESSION` → `OPENCODE_USER/PASS/SESSION` (values unchanged, session pin kept), added `OPENCODE_API=http://172.24.0.1:9898`, dropped the `MESH_NOTIFY_URL=` line.
|
||||
- Tests: new `src/opencode/opencode.service.spec.ts` (12 specs — exact wrapper text with/without `from` incl. em-dash and Basic auth header, workspace path match / name fallback / ready preference, session pin vs discovery-by-max-time.updated vs first-entry fallback, POST timeout = delivered success with no retry, GET failure retried then succeeds, give up after 4 attempts with the dropped log, disabled-when-no-creds no-op + construction warn). `mesh.service.spec.ts` notify block rewritten around a mocked `OpencodeService`: channel gate (default 3), off-channel skip, injected channel-0 case incl. `from` passthrough as undefined, self-echo skip for rx and injected; the ten webhook-specific tests (URL/body/retry/HTTP-error/give-up) are gone — retry semantics now live in the opencode spec.
|
||||
- Docs: README "Notify forwarder" section replaced by "Opencode notifier", env-var list updated (`MESH_NOTIFY_URL` bullet removed, `OPENCODE_*` documented); AGENTS.md webhook paragraph rewritten for the built-in notifier; MESHTASTIC.md notification rule updated. All FORWARDER_* mentions removed from tracked files (CHANGELOG history keeps them as-is).
|
||||
|
||||
## v0.13.1 — 2026-09-25 13:02
|
||||
|
||||
- Battery-low alerts now ride channel "testowy" (index 2) instead of the pinned send channel ("test", index 3): `BatteryAlertService` reads new env `BATTERY_ALERT_CHANNEL` at construction (default `2`, same pattern as its other env config) and passes it through the existing `MeshService.sendText(to, text, replyId?, channel?)` path — which gained an optional trailing `channel` argument defaulting to the pinned `MESH_SEND_CHANNEL`, so every other sender (API, MCP, weather) is untouched. Threshold, cooldown, merged one-message-per-cycle format and @mentions are unchanged.
|
||||
- Tests: battery specs now assert the send carries channel index 2 by default plus a `BATTERY_ALERT_CHANNEL` override case; the end-to-end case asserts the alert frame rides channel 2 while the pinned send channel stays at its default 3. All pre-existing specs stay green.
|
||||
|
||||
## v0.13.0 — 2026-09-25 12:34
|
||||
|
||||
- New non-AI `src/battery` module: `BatteryAlertModule` + `BatteryAlertService`, wired into `app.module.ts` exactly like the weather module (ScheduleModule.forRoot() + MeshModule). Every `BATTERY_CHECK_INTERVAL_MS` ms (default 60000, read at construction) it scans the same node state that backs `GET /api/nodes` and collects every node whose reported battery is at or below `BATTERY_ALERT_THRESHOLD` percent (default 20); when any qualify it sends AT MOST ONE broadcast per cycle containing all of them — never one message per node, and nothing at all when none qualify. The comparison is `<=` on purpose — a node sitting exactly at the threshold counts as low; tests pin this choice.
|
||||
- Alert format: `⚠️ battery low: @!<8-hex-lowercase-node-num> (<longName or shortName>) <X>%` with additional nodes appended as `, @!<hex> (<name>) <Y>%` (the single-node case is the same template with one entry) — Meshtastic mention syntax (`@!` + zero-padded 8-char lowercase hex, e.g. `@!09a2b3c4`), entries kept tight so common cases stay near the ~190-char single-packet limit; the name part falls back longName → shortName and is omitted entirely when a node reported no name. Broadcasts ride the pinned send channel via the existing `MeshService.sendText(BROADCAST_ADDR, text)` path (MESH_SEND_CHANNEL, default index 3) — no new transport.
|
||||
- Anti-spam: per-node cooldown of `BATTERY_ALERT_COOLDOWN_MS` ms (default 21600000 = 6 h) in an in-memory `Map<nodeNum, lastAlertEpochMs>` — only nodes outside their window are included in a batch and the alert timestamp is recorded for every node in it after sending, so a node is alerted at most once per window; the map resets on process restart by design (a node may get one duplicate alert after a deploy).
|
||||
- Live battery updates: `batteryLevel` was previously only populated from NodeInfo frames during the initial sync. Live NODEINFO_APP packets (port 4, serialized meshtastic.NodeInfo in Data.payload) now update `deviceMetrics` in the node database too — new `decodeNodeInfo()` + `PORT_NODEINFO_APP` in codec.ts, one branch in the existing packet-handling path — so alerts and `/api/nodes` reflect current values between syncs. Unknown nodes get a stub entry when they report metrics live; malformed payloads are skipped like every other port.
|
||||
- Tests: new `src/battery/battery.service.spec.ts` (mention hex padding/lowercase, batched message construction incl. multi-node append, name fallbacks and the 190-char ceiling, threshold boundary with default + custom env, no-battery skip, all qualifying nodes merged into exactly one broadcast, send-failure never throws, cooldown suppression within window + re-alert after expiry via fake timers, in-cooldown node excluded from a batch while an out-of-window node still triggers the single message) plus a DI spec mirroring weather's; an end-to-end case drives a real MeshService over the fake bridge and asserts the alert frame rides channel index 3 with the exact text. `mesh.service.spec.ts` gains a NODEINFO_APP live-update case (stale sync battery refreshed, other fields preserved, unknown-node stub). All pre-existing specs stay green.
|
||||
|
||||
## v0.12.8 — 2026-09-25 12:04
|
||||
|
||||
- Docs (MESHTASTIC.md response protocol): new standing rule per owner request ("remember to always replay on reply on channel when question is send here") — questions received via mesh are **answered on the channel**: every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session. All existing bullets (always-respond-first, channel-only/no DMs, owner authority 2594370888, language, NO_RESPONSE quirk, self-echo note) unchanged. No code changes — all 64 tests untouched and green.
|
||||
|
||||
## v0.12.7 — 2026-09-25 11:40
|
||||
|
||||
- `POST /api/send` and the MCP `send_message` tool now accept an optional `replyId` (integer in 1..0xffffffff) referencing an earlier message's packet id from `GET /api/messages` / `get_messages`. When set, the recipient app renders the sent text as a threaded reply/quote of that packet. The value rides Meshtastic's protocol-level reply mechanism: `Data.reply_id` (field 7) on the TEXT_MESSAGE_APP payload.
|
||||
- `buildTextToRadio(to, text, channelIndex, replyId?)` in `src/mesh/codec.ts` gained the optional param and sets **camelCase** `replyId` on the Data value (`replyId ?? 0`). The JS protobufs package silently drops snake_case `reply_id`, so camelCase is mandatory; a byte-level spec asserts wire tag `0x3d` (field 7, fixed32) appears iff `replyId` was given.
|
||||
- `MeshService.sendText(to, text, replyId?)` passes the value through to the codec. REST validation rejects non-integers and out-of-range values with a 400; MCP validates via zod (`z.number().int().min(1).max(0xffffffff)`). Omitting it leaves the field unset (proto3 default 0, not serialized) — existing callers are unaffected.
|
||||
- New specs: codec byte-level `0x3d` presence/absence check and a service outbound-capture case asserting `Data.replyId === <id>` when given and `0` when omitted. All 62 pre-existing tests stay green (64 total).
|
||||
- Docs: README endpoint + MCP tool lines, AGENTS.md API section, and the controller's `@ApiOperation` description now document the optional `replyId`.
|
||||
|
||||
## v0.12.6 — 2026-09-25 10:55
|
||||
|
||||
- Docs (MESHTASTIC.md response protocol): reverted the v0.12.5 reply rule per owner preference ("we will talk on that channel") — replies to messages from a specific node now go back **on the same channel as a broadcast** (no `to`), no direct messages; broadcasts are used for both replies and general announcements such as the hourly weather report. The always-respond-first bullet (**ALWAYS respond ... BEFORE starting any work it triggers**) and every other rule are unchanged. No code changes — `POST /api/send` and the MCP send tool already accept an omitted `to` (broadcast).
|
||||
|
||||
## v0.12.5 — 2026-09-25 10:45
|
||||
|
||||
- Docs (MESHTASTIC.md response protocol): replies to messages from a specific node now go back **as a direct message to that sender's node number** (`to` = the sender's node num), so they surface in the direct conversation thread ("flagged as reply") instead of as new channel posts. Broadcasts (no `to`) are reserved for general announcements such as the hourly weather report. No code changes — `POST /api/send` and the MCP send tool already accept a `to` target; all other rules (owner authority 2594370888, language, confirm-first-then-work, channel pinning, self-echo note, NO_RESPONSE quirk) unchanged.
|
||||
- MESHTASTIC.md confirm-first rule made explicit and mandatory: **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** (acknowledge first, then work) — the previous "first respond/confirm, then start the actual work" wording was not explicit enough and got violated in practice.
|
||||
|
||||
## v0.12.4 — 2026-09-25 10:17
|
||||
|
||||
- Notify forwarder (`ops/forwarder`) now resolves the opencode workspace + session dynamically per request instead of hardcoding them as env defaults: `GET {FORWARDER_API}/api/workspaces` → match by `path == FORWARDER_WORKSPACE_PATH` (fallback `name == "mesh-bot"`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. Fixes lost user messages after an opencode host restart regenerated the workspace UUID (the old hardcoded base URL 404'd on every notify POST).
|
||||
- New env config replaces `FORWARDER_BASE`: `FORWARDER_API` (default `http://127.0.0.1:9898`) and `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`); `FORWARDER_SESSION` is now an optional pin — when set, session discovery is skipped; empty/unset discovers dynamically. `FORWARDER_USER` / `FORWARDER_PASS` stay required. Each failure mode gets a clear 502 + log line: API unreachable, workspace not found, no sessions in workspace. The message POST itself (endpoint under `{proxyPath}/session/{sid}`, body and auth), timeout=15 semantics, 202-on-timeout and the `_reply` BrokenPipe guard are unchanged.
|
||||
- `docker-compose.yml`: forwarder environment block now passes `FORWARDER_API` / `FORWARDER_WORKSPACE_PATH` / `FORWARDER_SESSION` (empty default) — old `FORWARDER_BASE` line removed; bot service untouched.
|
||||
- `MeshService.postNotify()`: after `await fetch(...)`, checks `res.ok` and throws `Error('HTTP <status>')` when not ok, so a forwarder 5xx during an opencode restart is retried with the same backoff as network failures (the `res &&` guard keeps specs that stub fetch to resolve `undefined` green). One new spec: fetch resolves status 502 twice then 200 → exactly 3 attempts, success path. All 61 pre-existing tests stay green (62 total).
|
||||
- Docs: README "Notify forwarder" section rewritten for dynamic resolution + the new env vars; AGENTS.md webhook note gains a clause that the forwarder resolves the mesh-bot workspace/session from the opencode API per request (`FORWARDER_SESSION` pins a session when set).
|
||||
|
||||
## v0.12.3 — 2026-09-25 09:40
|
||||
|
||||
- Notify webhook POSTs are now retried instead of dropped on the first failure: `notifyRx`'s fire-and-forget POST runs through a retry loop with up to 4 attempts total (initial + 3 retries) and backoff delays `[2 s, 10 s, 30 s]` (~45 s window). Each failed attempt logs WARN with the attempt number and error; after the final failure it logs WARN `notify dropped after N attempts: ...` and stops. Success on any attempt ends the loop (unchanged success behavior/logging). Fire-and-forget semantics preserved — the RX handler never awaits the loop, which runs as an un-awaited async IIFE exactly like today's single POST. Self-echo filter and channel-match logic unchanged; backoff is hardcoded (no new env vars).
|
||||
- New private `MeshService.postNotify()` retry helper + injectable `sleep()` so specs can skip the delays without fake timers. Two new specs: fetch fails twice then succeeds → exactly 3 attempts, no dropped log, success path taken; fetch always fails → exactly 4 attempts, final WARN `notify dropped after 4 attempts`, nothing thrown. All 59 pre-existing tests stay green (61 total).
|
||||
- Docs: README `MESH_NOTIFY_URL` bullet and AGENTS.md webhook note now state failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
||||
|
||||
## v0.12.2 — 2026-09-25 08:44
|
||||
|
||||
- New compose service `forwarder` (`ops/forwarder`, `python:3.12-slim`, stdlib only, no pip installs): runs the notify forwarder that was previously a host-side `nohup` python script — receives the bot's outbound notify webhook POSTs (`{text, from?}` on `MESH_NOTIFY_URL`) and forwards each one as a message to an opencode API session (Basic auth).
|
||||
- `network_mode: host` → listens on the host's `0.0.0.0:4097`, so `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` keeps working unchanged from the bot container, and it reaches opencode at `127.0.0.1:9898`. No `networks:` key (host mode), no Traefik labels; `restart: unless-stopped`.
|
||||
- Config moves out of the script into compose environment from `.env` (gitignored): `FORWARDER_BASE` / `FORWARDER_SESSION` keep their current values as defaults, `FORWARDER_USER` / `FORWARDER_PASS` are required — the container exits on startup if either is missing. The real credentials are no longer hardcoded anywhere in the repo; nothing sensitive committed.
|
||||
- No app code changes — existing 59 tests stay green.
|
||||
|
||||
## v0.12.1 — 2026-09-25 07:57
|
||||
|
||||
- Hotfix for the v0.12.0 bootstrap crash (`UnknownDependenciesException`): `MeshModule` now exports `MeshService`, and `WeatherService`'s `import type { MeshService }` became a value import — with `emitDecoratorMetadata` the type-only import is erased, so the constructor's DI token was `undefined` at runtime even with the export (the unit suite instantiates services directly and never booted the module graph, which is why 58 green tests still shipped a broken app).
|
||||
- `docker-compose.yml`: `WEATHER_CRON` passthrough (`${WEATHER_CRON:-}`) mirroring the existing notify-var pattern — empty/unset falls back to the service default `'0 * * * *'`. No LAT/LON/CITY passthroughs (service-side defaults suffice).
|
||||
- New DI smoke spec `src/weather/di.spec.ts`: compiles `WeatherModule` with Nest's `Test.createTestingModule().compile()` (no `app.init()`, so no TCP connection) and asserts `WeatherService` resolves its `MeshService` from `MeshModule` — fails if the export regresses.
|
||||
|
||||
## v0.12.0 — 2026-09-25 07:42
|
||||
|
||||
- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code <n>`). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app.
|
||||
- Env config with defaults (read at construction): `WEATHER_CRON` (`'0 * * * *'`), `WEATHER_LAT` (51.2465), `WEATHER_LON` (22.5684), `WEATHER_CITY` ("Lublin").
|
||||
- New dependency: `@nestjs/schedule` (ScheduleModule.forRoot() in WeatherModule, registered in AppModule). No new REST endpoints — scheduled job only; send path, persistence, redaction, MCP and webhook logic untouched.
|
||||
- Tests: four new specs in `src/weather/weather.service.spec.ts` — exact formatted report from a sample payload; unknown WMO code → `code <n>` fallback; cron success path (stubbed fetch + MeshService mock) → `sendText(BROADCAST_ADDR, report)` called once with the Open-Meteo URL asserted; fetch failure → no send, no throw. All 54 pre-existing specs stay green (58 total).
|
||||
- Docs: README "Automated weather reports" section, AGENTS.md bullet in "Other".
|
||||
|
||||
## v0.11.3 — 2026-09-25 07:28
|
||||
|
||||
- Added MESHTASTIC.md communication-rules file; referenced from AGENTS.md.
|
||||
|
||||
+6
-3
@@ -14,14 +14,17 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
|
||||
|
||||
## Response protocol
|
||||
|
||||
- When doing any action triggered by a chat message: **first respond/confirm on the channel, then start the actual work** — so the user gets the fastest possible response.
|
||||
- Replies to messages received on a channel go back **on that same channel as a broadcast** (no `to`), not as a DM — unless the sender explicitly asks for a private reply.
|
||||
- **ALWAYS respond to a received mesh message on the Meshtastic BEFORE starting any work it triggers** — acknowledge first, then work. This is mandatory for every action triggered by a chat message; the confirmation goes out before you start (so the user gets the fastest possible response).
|
||||
- Replies to messages from a specific node go back **on the same channel as a broadcast** (no `to`) — do not send direct messages; per owner preference, all conversation happens on the channel. Broadcasts are used for both replies and general announcements such as the hourly weather report.
|
||||
- **Questions are answered on the channel** — every question sent via mesh gets its actual answer broadcast back on the same channel when work completes; an acknowledgement alone is not sufficient, and the answer must not stay only in the private agent session.
|
||||
|
||||
## Channels & sending
|
||||
|
||||
- All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel.
|
||||
- Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted.
|
||||
- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) on that channel get an automatic reply routed through `MeshService.respond()` — DM back to the original sender, broadcast when the original was a broadcast — riding the original message's channel as a threaded quote (`replyId` = the trigger packet id). Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything.
|
||||
|
||||
## Notifications (mesh → agent)
|
||||
|
||||
- RX text on the notify channel (`MESH_NOTIFY_CHANNEL`, default 3) triggers an outbound webhook POST `{text, from}` to `MESH_NOTIFY_URL` (host forwarder :4097 → opencode session). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
- RX text on a channel in the notify list (`MESH_NOTIFY_CHANNEL`, comma-separated indices, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
- Channel 0 is the shared default channel where other users talk: it carries real DMs when both sender and recipient are present, and the bot listens there too — but only directed messages trigger notifications; ch0 broadcasts are ignored. Synthetic injected messages (channel 0) are exempt from this rule.
|
||||
|
||||
@@ -26,11 +26,13 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
||||
All API endpoints live under the `/api` prefix:
|
||||
|
||||
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
||||
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
||||
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet
|
||||
- `POST /api/respond` — body `{ "messageId": <packet id>, "content": "..." }`, returns `{ id }`. Reply to an earlier message by packet id (from `GET /api/messages`) with automatic routing: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down
|
||||
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
||||
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
|
||||
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
|
||||
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from` and `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
|
||||
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
|
||||
- `GET /api/forwarded` — the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel in the `MESH_NOTIFY_CHANNEL` list (ch0: directed DMs only), not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
|
||||
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
|
||||
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
|
||||
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
|
||||
@@ -42,10 +44,12 @@ Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
||||
curl http://localhost:3000/api/status
|
||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||
curl -X POST http://localhost:3000/api/respond -H 'content-type: application/json' -d '{"messageId":123456789,"content":"on my way"}'
|
||||
curl http://localhost:3000/api/channels
|
||||
curl http://localhost:3000/api/device
|
||||
curl http://localhost:3000/api/messages
|
||||
curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}'
|
||||
curl http://localhost:3000/api/forwarded
|
||||
curl http://localhost:3000/api/telemetry
|
||||
curl http://localhost:3000/api/nodes
|
||||
curl http://localhost:3000/api/nodes/kamil
|
||||
@@ -64,10 +68,12 @@ channel. Override with `MESH_SEND_CHANNEL`.
|
||||
The bot also exposes an MCP (Model Context Protocol) endpoint at
|
||||
`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's
|
||||
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
|
||||
endpoints — no new logic, same channel pinning:
|
||||
endpoints — no new logic; sends ride the pinned channel like their REST
|
||||
counterparts (`respond` rides the original message's channel):
|
||||
|
||||
- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`)
|
||||
- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors
|
||||
- `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors
|
||||
- `respond(messageId, content)` — reply to an earlier message by packet id (from `get_messages`) with automatic routing: rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with `replyId = messageId` (threaded quote); returns `{ id }`; invalid args or an unknown id are tool errors
|
||||
- `list_channels` — device channels (keys redacted)
|
||||
- `list_nodes` — known nodes as lean summaries
|
||||
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
||||
@@ -89,17 +95,39 @@ curl -X POST http://localhost:3000/mcp \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
```
|
||||
|
||||
## Automated weather reports
|
||||
|
||||
Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Open-Meteo](https://open-meteo.com/) (free, no API key) and broadcasts a one-line report on the pinned send channel (index 3, `test`) — e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`. A failed fetch or send only logs a warn; it never crashes the app.
|
||||
|
||||
- `WEATHER_CRON` (default `'0 * * * *'`) — cron expression for the report schedule
|
||||
- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo
|
||||
- `WEATHER_CITY` (default `"Lublin"`) — city name in the report line
|
||||
|
||||
## Opencode notifier
|
||||
|
||||
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list, it pushes it to the mesh-bot folder's live opencode session (Basic auth). On ch0 only directed DMs are pushed — broadcasts there are ignored. The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
||||
|
||||
Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo:
|
||||
|
||||
- `OPENCODE_API` (default `http://172.24.0.1:9898`) — opencode API base URL as seen from the bot container (dev_net bridge → host; opencode binds 0.0.0.0 on the host)
|
||||
- `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match)
|
||||
- `OPENCODE_SESSION` (optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per request
|
||||
- `OPENCODE_USER` / `OPENCODE_PASS` — Basic auth credentials; missing/empty **disables** the notifier (one startup log line, no-op) instead of failing
|
||||
|
||||
## Env vars
|
||||
|
||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||
- `MESH_PORT` (default `4403`) — bridge port
|
||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
||||
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
||||
- `MESH_NOTIFY_URL` — outbound notification webhook URL; when set (non-empty), an inbound rx text message arriving on channel `MESH_NOTIFY_CHANNEL` fires a fire-and-forget HTTP POST with JSON body `{text, from}` (`from` omitted when absent). Synthetic injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). Unset or empty = disabled (no fetch at all)
|
||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index that triggers the notify webhook
|
||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified)
|
||||
- `COMMAND_CHANNEL` (default `2`) — channel index of the auto-responder: exact `#command` texts on that channel get an automatic reply (directed back to the sender, else broadcast); currently only `#ping` → `#pong!`
|
||||
- `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation)
|
||||
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
|
||||
- `PORT` (default `3000`) — HTTP listen port
|
||||
- `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`) — OpenAI-compatible LLM server base URL used by the in-process `LlmService` (`src/llm`; no auth)
|
||||
- `LLM_MODEL` (default `qwen3.8-27b`) — model id sent to `/chat/completions`
|
||||
- `LLM_TIMEOUT_MS` (default `60000`) — per-request timeout for LLM calls (the local 27B model can be slow)
|
||||
|
||||
Note: the serial bridge accepts only ONE client at a time — if another app is
|
||||
connected to the radio, this bot will not get a connection.
|
||||
|
||||
+6
-2
@@ -6,8 +6,13 @@ services:
|
||||
MESH_PORT: '4403'
|
||||
MESH_DB_PATH: ./data/mesh-bot.db
|
||||
MESH_POLL_MS: '1000'
|
||||
MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-}
|
||||
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
||||
OPENCODE_API: ${OPENCODE_API:-http://172.24.0.1:9898}
|
||||
OPENCODE_WORKSPACE_PATH: ${OPENCODE_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
|
||||
OPENCODE_SESSION: ${OPENCODE_SESSION:-}
|
||||
OPENCODE_USER: ${OPENCODE_USER:-}
|
||||
OPENCODE_PASS: ${OPENCODE_PASS:-}
|
||||
WEATHER_CRON: ${WEATHER_CRON:-}
|
||||
volumes:
|
||||
- mesh_data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -18,7 +23,6 @@ services:
|
||||
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
||||
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
||||
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
||||
|
||||
volumes:
|
||||
mesh_data:
|
||||
|
||||
|
||||
Generated
+172
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.5.0",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mesh-bot",
|
||||
"version": "0.5.0",
|
||||
"version": "0.19.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.15.0",
|
||||
@@ -15,8 +15,10 @@
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
"@nestjs/platform-express": "^12.0.1",
|
||||
"@nestjs/schedule": "^12.0.2",
|
||||
"@nestjs/swagger": "^12.0.2",
|
||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -976,6 +978,22 @@
|
||||
"@nestjs/core": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz",
|
||||
"integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0 || ^12.0.0",
|
||||
"@nestjs/core": "^11.0.0 || ^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.0.tgz",
|
||||
@@ -1131,6 +1149,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.151.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
||||
@@ -1975,6 +2005,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -2313,6 +2349,18 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -2815,6 +2863,23 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.7.0",
|
||||
"luxon": "~3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/intcreator"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3239,6 +3304,45 @@
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
|
||||
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3790,6 +3894,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -4218,6 +4334,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz",
|
||||
@@ -4799,6 +4924,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -5454,6 +5594,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.5",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||
@@ -6098,6 +6253,21 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.11.3",
|
||||
"version": "0.21.7",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -27,8 +27,10 @@
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
"@nestjs/platform-express": "^12.0.1",
|
||||
"@nestjs/schedule": "^12.0.2",
|
||||
"@nestjs/swagger": "^12.0.2",
|
||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
+13
-1
@@ -1,7 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BatteryAlertModule } from './battery/battery.module.js';
|
||||
import { CommandModule } from './commands/command.module.js';
|
||||
import { LlmModule } from './llm/llm.module.js';
|
||||
import { MeshModule } from './mesh/mesh.module.js';
|
||||
import { RssModule } from './rss/rss.module.js';
|
||||
import { WeatherModule } from './weather/weather.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule],
|
||||
imports: [
|
||||
MeshModule,
|
||||
WeatherModule,
|
||||
BatteryAlertModule,
|
||||
CommandModule,
|
||||
LlmModule,
|
||||
RssModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshModule } from '../mesh/mesh.module.js';
|
||||
import { BatteryAlertService } from './battery.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule],
|
||||
providers: [BatteryAlertService],
|
||||
exports: [BatteryAlertService],
|
||||
})
|
||||
export class BatteryAlertModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR, extractFrames } from '../mesh/codec.js';
|
||||
import { nodeInfoFrame, startBridge, waitFor } from '../mesh/fake-bridge.js';
|
||||
import type { MeshService, NodeSummary } from '../mesh/mesh.service.js';
|
||||
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
||||
import {
|
||||
BatteryAlertService,
|
||||
formatAlert,
|
||||
mention,
|
||||
} from './battery.service.js';
|
||||
|
||||
function node(
|
||||
num: number,
|
||||
batteryLevel?: number,
|
||||
longName?: string,
|
||||
shortName?: string,
|
||||
): NodeSummary {
|
||||
const n: NodeSummary = { num, lastHeard: null };
|
||||
if (batteryLevel !== undefined) n.batteryLevel = batteryLevel;
|
||||
if (longName) n.longName = longName;
|
||||
if (shortName) n.shortName = shortName;
|
||||
return n;
|
||||
}
|
||||
|
||||
function makeService(
|
||||
nodes: NodeSummary[],
|
||||
sendText?: ReturnType<typeof vi.fn>,
|
||||
): BatteryAlertService {
|
||||
const mesh = { nodes: async () => nodes, sendText } as unknown as MeshService;
|
||||
return new BatteryAlertService(mesh);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// defaults under test: threshold 20, cooldown 6 h, channel 2 ("testowy")
|
||||
delete process.env.BATTERY_ALERT_THRESHOLD;
|
||||
delete process.env.BATTERY_ALERT_COOLDOWN_MS;
|
||||
delete process.env.BATTERY_ALERT_CHANNEL;
|
||||
});
|
||||
|
||||
describe('mention', () => {
|
||||
it('zero-pads to 8 chars and lowercases the hex', () => {
|
||||
expect(mention(0xabc)).toBe('@!00000abc'); // padded
|
||||
expect(mention(0xffffffff)).toBe('@!ffffffff'); // already 8 chars — no padding needed
|
||||
expect(mention(0xdeadbeef)).toBe('@!deadbeef'); // lowercase
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAlert', () => {
|
||||
it('builds the single-node message with longName', () => {
|
||||
expect(formatAlert([node(0x1234abcd, 15, 'Kamil SP8MK/P')])).toBe(
|
||||
'⚠️ battery low: @!1234abcd (Kamil SP8MK/P) 15%',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends additional nodes as ", <entry>" in one line', () => {
|
||||
expect(
|
||||
formatAlert([node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')]),
|
||||
).toBe('⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%');
|
||||
});
|
||||
|
||||
it('falls back to shortName and omits the name part when neither is known', () => {
|
||||
expect(formatAlert([node(0xabc, 9, undefined, 'KK')])).toBe(
|
||||
'⚠️ battery low: @!00000abc (KK) 9%',
|
||||
);
|
||||
expect(formatAlert([node(0xabc, 9)])).toBe('⚠️ battery low: @!00000abc 9%');
|
||||
});
|
||||
|
||||
it('stays under the ~190-char single-packet limit', () => {
|
||||
const msg = formatAlert([node(0xffffffff, 5, 'A'.repeat(64))]);
|
||||
expect(msg.length).toBeLessThan(190);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BatteryAlertService check threshold', () => {
|
||||
it('alerts at and below the default threshold (20) but not above', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const svc = makeService([node(1, 19), node(2, 20), node(3, 21)], sendText);
|
||||
|
||||
await svc.check();
|
||||
|
||||
// <= choice: exactly 20 qualifies too — both in ONE batched message, on the default channel 2
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
BROADCAST_ADDR,
|
||||
'⚠️ battery low: @!00000001 19%, @!00000002 20%',
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('honors a custom BATTERY_ALERT_THRESHOLD', async () => {
|
||||
process.env.BATTERY_ALERT_THRESHOLD = '50';
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
try {
|
||||
const svc = makeService([node(1, 50), node(2, 51)], sendText);
|
||||
|
||||
await svc.check();
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
BROADCAST_ADDR,
|
||||
'⚠️ battery low: @!00000001 50%',
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.BATTERY_ALERT_THRESHOLD;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips nodes without a reported battery level', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const svc = makeService([node(1), node(2, 87)], sendText); // no battery / healthy
|
||||
|
||||
await svc.check();
|
||||
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const svc = makeService(
|
||||
[node(0xabc, 5, 'A'), node(0xdef, 3, undefined, 'B')],
|
||||
sendText,
|
||||
);
|
||||
|
||||
await svc.check();
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1); // never one message per node
|
||||
expect(sendText.mock.calls[0]).toEqual([
|
||||
BROADCAST_ADDR,
|
||||
'⚠️ battery low: @!00000abc (A) 5%, @!00000def (B) 3%',
|
||||
undefined,
|
||||
2, // default channel "testowy" — not the pinned send channel (3)
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends on a custom BATTERY_ALERT_CHANNEL when set', async () => {
|
||||
process.env.BATTERY_ALERT_CHANNEL = '5';
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
try {
|
||||
const svc = makeService([node(0xabc, 5)], sendText);
|
||||
|
||||
await svc.check();
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText.mock.calls[0]).toEqual([
|
||||
BROADCAST_ADDR,
|
||||
'⚠️ battery low: @!00000abc 5%',
|
||||
undefined,
|
||||
5,
|
||||
]);
|
||||
} finally {
|
||||
delete process.env.BATTERY_ALERT_CHANNEL;
|
||||
}
|
||||
});
|
||||
|
||||
it('never throws when a send fails', async () => {
|
||||
const svc = makeService(
|
||||
[node(1, 5)],
|
||||
vi.fn(async (..._args: unknown[]) => {
|
||||
throw new Error('bridge down');
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(svc.check()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BatteryAlertService check cooldown', () => {
|
||||
it('suppresses re-alerts within the window and allows them after expiry (default 6 h)', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const svc = makeService([node(0xabc, 5)], sendText);
|
||||
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(1); // first alert
|
||||
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(1); // immediate re-check — suppressed
|
||||
|
||||
vi.advanceTimersByTime(6 * 3_600_000 - 1);
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(1); // still inside the window
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(2); // cooldown elapsed — re-alerted
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes a node inside its cooldown from the batch while another outside it still triggers one message', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
// only B is known at first — alert it, then A appears while B is still in cooldown
|
||||
const nodes = [node(0xdef, 3)];
|
||||
const svc = makeService(nodes, sendText);
|
||||
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText.mock.calls[0][1]).toBe('⚠️ battery low: @!00000def 3%');
|
||||
|
||||
nodes.push(node(0xabc, 5)); // A is due, B is inside its window
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(2); // one message — only A
|
||||
expect(sendText.mock.calls[1][1]).toBe('⚠️ battery low: @!00000abc 5%');
|
||||
|
||||
vi.advanceTimersByTime(6 * 3_600_000); // B's cooldown expires too
|
||||
await svc.check();
|
||||
expect(sendText).toHaveBeenCalledTimes(3); // both due again — one batched message with both
|
||||
expect(sendText.mock.calls[2][1]).toBe(
|
||||
'⚠️ battery low: @!00000def 3%, @!00000abc 5%',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('BatteryAlertService check end-to-end (real MeshService)', () => {
|
||||
it('broadcasts the alert on "testowy" (channel index 2), not the pinned send channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
delete process.env.MESH_SEND_CHANNEL; // default pinned send channel is 3 — the alert must NOT ride it
|
||||
const mesh = new RealMeshService();
|
||||
try {
|
||||
mesh.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && mesh.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(
|
||||
nodeInfoFrame(0xabc, {
|
||||
user: { id: '!a', longName: 'Alpha' },
|
||||
deviceMetrics: { batteryLevel: 5 },
|
||||
}),
|
||||
);
|
||||
await waitFor(async () => (await mesh.nodes()).length === 1);
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||
const svc = new BatteryAlertService(mesh);
|
||||
await svc.check();
|
||||
|
||||
const readOutbound = (): P.Mesh.MeshPacket[] => {
|
||||
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||
let off = 0;
|
||||
for (const c of out) {
|
||||
merged.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
const { packets } = extractFrames(merged);
|
||||
// outbound starts with the want_config_id frame — collect only text packets, in order
|
||||
return packets.flatMap((p) => {
|
||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||
if (tr.payloadVariant?.case === 'packet')
|
||||
return [tr.payloadVariant.value];
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
await waitFor(() => readOutbound().length === 1);
|
||||
const pkt = readOutbound()[0];
|
||||
expect(pkt.channel).toBe(2); // "testowy" — not the pinned send channel (3) or primary (0)
|
||||
const data =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(new TextDecoder().decode(data?.payload ?? new Uint8Array())).toBe(
|
||||
'⚠️ battery low: @!00000abc (Alpha) 5%',
|
||||
);
|
||||
} finally {
|
||||
delete process.env.MESH_HOST;
|
||||
delete process.env.MESH_PORT;
|
||||
delete process.env.MESH_SEND_CHANNEL;
|
||||
mesh.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { NodeSummary } from '../mesh/mesh.service.js';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
|
||||
/** Meshtastic mention of a node: `@!` + 8-char zero-padded lowercase hex, e.g. `@!09a2b3c4`. */
|
||||
export function mention(nodeNum: number): string {
|
||||
return `@!${nodeNum.toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
/** One tight entry per node, e.g. `@!09a2b3c4 (Kamil SP8MK/P) 15%`. */
|
||||
export function formatEntry(node: NodeSummary): string {
|
||||
// no name reported → omit the paren part rather than invent one
|
||||
const name = node.longName ?? node.shortName;
|
||||
return `${mention(node.num)}${name ? ` (${name})` : ''} ${node.batteryLevel}%`;
|
||||
}
|
||||
|
||||
/** One short single-packet alert line for a whole batch, e.g. `⚠️ battery low: @!09a2b3c4 (Kamil SP8MK/P) 15%, @!0def5678 (KK) 9%` — entries kept tight so common cases stay near the ~190-char single-packet limit. */
|
||||
export function formatAlert(nodes: NodeSummary[]): string {
|
||||
return `⚠️ battery low: ${nodes.map(formatEntry).join(', ')}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BatteryAlertService {
|
||||
private readonly logger = new Logger(BatteryAlertService.name);
|
||||
// read at construction, like MeshService's env config
|
||||
private readonly threshold = parseInt(
|
||||
process.env.BATTERY_ALERT_THRESHOLD ?? '20',
|
||||
10,
|
||||
);
|
||||
private readonly cooldownMs = parseInt(
|
||||
process.env.BATTERY_ALERT_COOLDOWN_MS ?? '21600000', // default 6 h
|
||||
10,
|
||||
);
|
||||
// alerts ride "testowy" (index 2), not the pinned send channel — owner decision
|
||||
private readonly alertChannel = parseInt(
|
||||
process.env.BATTERY_ALERT_CHANNEL ?? '2',
|
||||
10,
|
||||
);
|
||||
// ponytail: in-memory cooldown map — resets on process restart (a node may get one duplicate alert after a deploy)
|
||||
private lastAlertAt = new Map<number, number>();
|
||||
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
/** Interval scan of the same node state that backs GET /api/nodes; collects every low-battery node not recently alerted and sends AT MOST ONE broadcast per cycle containing all of them. Never throws — a failed check must not crash the app. */
|
||||
@Interval(parseInt(process.env.BATTERY_CHECK_INTERVAL_MS ?? '60000', 10))
|
||||
async check(): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const due = (await this.mesh.nodes()).filter((n) =>
|
||||
this.shouldAlert(n, now),
|
||||
);
|
||||
if (due.length === 0) return; // nothing to report — send nothing
|
||||
await this.alert(due);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[battery] check failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldAlert(node: NodeSummary, now: number): boolean {
|
||||
if (node.batteryLevel === undefined) return false; // no battery reported — skip
|
||||
// <= on purpose: a node at exactly the threshold is already "low" (spec allowed either < or <=; tests pin this choice)
|
||||
if (!(node.batteryLevel <= this.threshold)) return false;
|
||||
const last = this.lastAlertAt.get(node.num);
|
||||
return last === undefined || now - last >= this.cooldownMs;
|
||||
}
|
||||
|
||||
private async alert(nodes: NodeSummary[]): Promise<void> {
|
||||
// one broadcast per cycle for the whole batch — never one message per node; rides BATTERY_ALERT_CHANNEL ("testowy", default index 2) via the same MeshService.sendText path as every other bot send
|
||||
await this.mesh.sendText(
|
||||
BROADCAST_ADDR,
|
||||
formatAlert(nodes),
|
||||
undefined,
|
||||
this.alertChannel,
|
||||
);
|
||||
const now = Date.now();
|
||||
for (const n of nodes) this.lastAlertAt.set(n.num, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
import { BatteryAlertModule } from './battery.module.js';
|
||||
import { BatteryAlertService } from './battery.service.js';
|
||||
|
||||
describe('DI wiring (module graph)', () => {
|
||||
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||
it('resolves BatteryAlertService with MeshService provided by MeshModule', async () => {
|
||||
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [BatteryAlertModule],
|
||||
}).compile();
|
||||
expect(moduleRef.get(BatteryAlertService)).toBeInstanceOf(
|
||||
BatteryAlertService,
|
||||
);
|
||||
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshModule } from '../mesh/mesh.module.js';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
import { RssModule } from '../rss/rss.module.js';
|
||||
import { RssService } from '../rss/rss.service.js';
|
||||
import { CommandService } from './command.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule, RssModule], // one-way only — the MeshService reverse edge is wired in the factory below, not via DI
|
||||
providers: [
|
||||
{
|
||||
provide: CommandService,
|
||||
useFactory: (mesh: MeshService, rss: RssService) => {
|
||||
const commands = new CommandService(mesh, rss);
|
||||
// break the cycle: MeshService can't inject us (its own factory would deadlock on this edge), so attach ourselves once we exist — mesh is guaranteed created here because it's our injected dependency
|
||||
mesh.attachCommands(commands);
|
||||
return commands;
|
||||
},
|
||||
inject: [MeshService, RssService],
|
||||
},
|
||||
],
|
||||
exports: [CommandService],
|
||||
})
|
||||
export class CommandModule {}
|
||||
@@ -0,0 +1,386 @@
|
||||
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 { RssService } from '../rss/rss.service.js';
|
||||
import {
|
||||
startBridge,
|
||||
textPacketFrame,
|
||||
waitFor,
|
||||
outboundPackets,
|
||||
KAMIL_NUM,
|
||||
} from '../mesh/fake-bridge.js';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
||||
import { CommandService } from './command.service.js';
|
||||
|
||||
function makeService(
|
||||
sendText?: ReturnType<typeof vi.fn>,
|
||||
respond?: ReturnType<typeof vi.fn>,
|
||||
): [CommandService, MeshService] {
|
||||
const mesh = {
|
||||
sendText: sendText ?? vi.fn(),
|
||||
respond: respond ?? vi.fn().mockResolvedValue({ id: 1 }),
|
||||
} as unknown as MeshService;
|
||||
return [new CommandService(mesh), mesh];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
|
||||
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
|
||||
});
|
||||
|
||||
describe('CommandService.onRx', () => {
|
||||
it('replies #ping via respond() — threaded quote of the trigger packet, no direct sendText', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping', 42, 7);
|
||||
expect(mesh.respond).toHaveBeenCalledWith(7, '#pong!');
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replies #help with a short single-message list of all commands', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#help', 42, 7);
|
||||
expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line
|
||||
const text = mesh.respond.mock.calls[0][1];
|
||||
expect(text).toContain('#ping');
|
||||
expect(text).toContain('#rss');
|
||||
});
|
||||
|
||||
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
||||
});
|
||||
|
||||
it('falls back to a broadcast sendText on COMMAND_CHANNEL when the trigger has no packet id (no sender)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping'); // no from, no id — defensive path
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(
|
||||
BROADCAST_ADDR,
|
||||
'#pong!',
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the same text on other channels (ch0 and ch3)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(0, '#ping', 42);
|
||||
await svc.onRx(3, '#ping', 42);
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-command text on the command channel', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, 'hello there', 42);
|
||||
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches command names exactly and case-sensitively', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#Ping', 42);
|
||||
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before matching', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, ' #ping ', 42, 9);
|
||||
expect(mesh.respond).toHaveBeenCalledWith(9, '#pong!');
|
||||
});
|
||||
|
||||
it('honors the COMMAND_CHANNEL env override', async () => {
|
||||
process.env.COMMAND_CHANNEL = '5';
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping', 42); // old default no longer triggers
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
await svc.onRx(5, '#ping', 42, 8);
|
||||
expect(mesh.respond).toHaveBeenCalledWith(8, '#pong!');
|
||||
});
|
||||
|
||||
it('sends nothing when a handler returns void', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('silent', () => undefined);
|
||||
await svc.onRx(2, '#silent', 42);
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is extensible: a second registered command dispatches through the same path', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
|
||||
await svc.onRx(2, '#status', 99, 5);
|
||||
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
|
||||
});
|
||||
|
||||
it('sends each line of an array reply as its own threaded respond() to the same packet id', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('multi', () => ['one: first item', 'two: second item']);
|
||||
await svc.onRx(2, '#multi', 42, 9);
|
||||
expect(mesh.respond).toHaveBeenCalledTimes(2);
|
||||
expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']);
|
||||
expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']);
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
|
||||
process.env.MESH_MULTI_SEND_DELAY_MS = '50'; // short so the test stays fast
|
||||
let sends = 0;
|
||||
const respond = vi.fn(async () => {
|
||||
sends++;
|
||||
return { id: 1 };
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const [svc, mesh] = makeService(undefined, respond);
|
||||
svc.register('multi3', () => ['a: one', 'b: two', 'c: three']);
|
||||
|
||||
const p = svc.onRx(2, '#multi3', 42, 9);
|
||||
// flush microtasks without advancing time — the first line must already be out (no delay before it)
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sends).toBe(1);
|
||||
|
||||
// each subsequent line waits exactly MESH_MULTI_SEND_DELAY_MS after the previous one landed
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(sends).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(sends).toBe(3);
|
||||
|
||||
await p;
|
||||
expect(mesh.respond).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
delete process.env.MESH_MULTI_SEND_DELAY_MS;
|
||||
}
|
||||
});
|
||||
|
||||
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('multi', () => ['one: first item', 'two: second item']);
|
||||
await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender
|
||||
expect(mesh.sendText).toHaveBeenCalledTimes(2);
|
||||
expect(mesh.sendText.mock.calls[0]).toEqual([
|
||||
42,
|
||||
'one: first item',
|
||||
undefined,
|
||||
2,
|
||||
]);
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('boom', () => {
|
||||
throw new Error('kaboom');
|
||||
});
|
||||
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
|
||||
expect(mesh.respond).not.toHaveBeenCalled();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a failing respond (unknown id / bridge down) without throwing', async () => {
|
||||
const [svc] = makeService(
|
||||
undefined,
|
||||
vi.fn().mockRejectedValue(new Error('no message with id 7')),
|
||||
);
|
||||
await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('swallows a failing send (mesh down) without throwing', async () => {
|
||||
const [svc] = makeService(
|
||||
vi.fn().mockRejectedValue(new Error('no bridge')),
|
||||
);
|
||||
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path
|
||||
});
|
||||
});
|
||||
|
||||
describe('#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)', () => {
|
||||
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
try {
|
||||
// wire the responder exactly like command.module.ts does in production
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||
|
||||
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
||||
|
||||
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
text: '#pong!',
|
||||
channel: 2,
|
||||
});
|
||||
|
||||
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
|
||||
await waitFor(() => outboundPackets(out).length >= 1);
|
||||
const pkt = outboundPackets(out)[0]!;
|
||||
expect(pkt.to).toBe(KAMIL_NUM);
|
||||
expect(pkt.channel).toBe(2);
|
||||
const data =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(data?.replyId).toBe(1);
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
try {
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
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({
|
||||
dir: 'tx',
|
||||
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
||||
text: '#pong!',
|
||||
channel: 2,
|
||||
});
|
||||
|
||||
await waitFor(() => outboundPackets(out).length >= 1);
|
||||
const pkt = outboundPackets(out)[0]!;
|
||||
expect(pkt.to).toBe(BROADCAST_ADDR);
|
||||
expect(pkt.channel).toBe(2);
|
||||
const data =
|
||||
pkt.payloadVariant?.case === 'decoded'
|
||||
? pkt.payloadVariant.value
|
||||
: undefined;
|
||||
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not answer #ping arriving on a non-command channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
try {
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
|
||||
|
||||
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'rx',
|
||||
text: '#ping',
|
||||
channel: 3,
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import type { RssService } from '../rss/rss.service.js';
|
||||
|
||||
/** Context handed to a command handler: the trimmed text plus sender/packet id when present. */
|
||||
export interface CommandContext {
|
||||
/** the trimmed rx text, e.g. `#ping` */
|
||||
text: string;
|
||||
/** sender node num — omitted for broadcasts/unknown senders */
|
||||
from?: number;
|
||||
/** packet id of the triggering message */
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */
|
||||
export type CommandHandler = (
|
||||
ctx: CommandContext,
|
||||
) => string | string[] | undefined | Promise<string | string[] | undefined>;
|
||||
|
||||
@Injectable()
|
||||
export class CommandService {
|
||||
private readonly logger = new Logger(CommandService.name);
|
||||
// read at construction, like other services' env config
|
||||
private readonly commandChannel = parseInt(
|
||||
process.env.COMMAND_CHANNEL ?? '2',
|
||||
10,
|
||||
);
|
||||
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
|
||||
private readonly multiSendDelayMs = parseInt(
|
||||
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
|
||||
10,
|
||||
);
|
||||
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
|
||||
private readonly commands = new Map<string, CommandHandler>();
|
||||
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
|
||||
private readonly prefixes = new Map<string, CommandHandler>();
|
||||
|
||||
/** mesh is optional so tests can construct without a bridge; production DI always passes one. */
|
||||
constructor(
|
||||
private readonly mesh?: MeshService,
|
||||
rss?: RssService,
|
||||
) {
|
||||
this.register('ping', () => '#pong!');
|
||||
// single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line
|
||||
this.register('help', () =>
|
||||
[
|
||||
'#ping - alive check',
|
||||
'#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));
|
||||
}
|
||||
|
||||
/** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
|
||||
register(name: string, fn: CommandHandler): void {
|
||||
this.commands.set(name, fn);
|
||||
}
|
||||
|
||||
/** Register a prefix handler: owns bare `#<name>` and everything starting with `#<name> `. Exact matches win over prefixes. */
|
||||
registerPrefix(name: string, fn: CommandHandler): void {
|
||||
this.prefixes.set(name, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven entry point called by MeshService for every decoded rx text.
|
||||
* No-op unless on COMMAND_CHANNEL with a case-sensitive `#command` match — exact name or registered prefix family (`#rss ...`).
|
||||
* Replies go through MeshService.respond(id, reply): auto-routed to the original message's
|
||||
* destination and channel as a threaded quote (replyId = id). Only when the trigger has no packet
|
||||
* id does it fall back to legacy sendText routing on COMMAND_CHANNEL. Never throws — a failed
|
||||
* command must not crash the app.
|
||||
*/
|
||||
async onRx(
|
||||
channel: number,
|
||||
text: string,
|
||||
from?: number,
|
||||
id?: number,
|
||||
): Promise<void> {
|
||||
if (channel !== this.commandChannel) return;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers
|
||||
const name = trimmed.slice(1);
|
||||
let handler = this.commands.get(name);
|
||||
if (!handler) {
|
||||
for (const [prefix, fn] of this.prefixes) {
|
||||
// a prefix owns bare `#<prefix>` and everything starting with `#<prefix> ` — e.g. 'rss' → #rss / #rss add ...
|
||||
if (name === prefix || name.startsWith(prefix + ' ')) {
|
||||
handler = fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!handler) return; // exact, case-sensitive match — no partials
|
||||
try {
|
||||
const reply = await handler({ text: trimmed, from, id });
|
||||
if (reply === undefined || reply === null) return; // void = no reply
|
||||
// an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
|
||||
const lines = Array.isArray(reply) ? reply : [reply];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i > 0 && this.multiSendDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.multiSendDelayMs),
|
||||
);
|
||||
}
|
||||
const line = lines[i];
|
||||
if (id !== undefined && this.mesh) {
|
||||
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
|
||||
await this.mesh.respond(id, line);
|
||||
} else {
|
||||
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
||||
await this.mesh?.sendText(
|
||||
from ?? BROADCAST_ADDR,
|
||||
line,
|
||||
undefined,
|
||||
this.commandChannel,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
import { CommandModule } from './command.module.js';
|
||||
import { CommandService } from './command.service.js';
|
||||
|
||||
describe('DI wiring (module graph)', () => {
|
||||
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||
it('resolves CommandService with MeshService provided by MeshModule', async () => {
|
||||
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CommandModule],
|
||||
}).compile();
|
||||
expect(moduleRef.get(CommandService)).toBeInstanceOf(CommandService);
|
||||
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||
});
|
||||
|
||||
// the MeshService <-> CommandService cycle must resolve to live references on both sides, not undefined
|
||||
it('wires both sides of the circular dependency with real instances', async () => {
|
||||
process.env.MESH_DB_PATH = ':memory:';
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CommandModule],
|
||||
}).compile();
|
||||
const mesh = moduleRef.get(MeshService);
|
||||
const commands = moduleRef.get(CommandService);
|
||||
expect((commands as unknown as { mesh?: MeshService }).mesh).toBe(mesh);
|
||||
expect((mesh as unknown as { commands?: CommandService }).commands).toBe(
|
||||
commands,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LlmService } from './llm.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [LlmService],
|
||||
exports: [LlmService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatMessage } from './llm.service.js';
|
||||
import { LlmService } from './llm.service.js';
|
||||
|
||||
const BASE = 'http://192.168.1.70:1111/v1';
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => body } as Response;
|
||||
}
|
||||
|
||||
/** Fresh service with a clean env (explicit base/model/timeout). */
|
||||
function makeService(): LlmService {
|
||||
process.env.LLM_API_BASE = BASE;
|
||||
process.env.LLM_MODEL = 'qwen3.8-27b';
|
||||
process.env.LLM_TIMEOUT_MS = '60000';
|
||||
return new LlmService();
|
||||
}
|
||||
|
||||
describe('LlmService', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
for (const k of ['LLM_API_BASE', 'LLM_MODEL', 'LLM_TIMEOUT_MS'])
|
||||
delete process.env[k];
|
||||
});
|
||||
|
||||
it('listModels returns the data[].id strings from GET /models', async () => {
|
||||
const svc = makeService();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
data: [
|
||||
{ id: 'qwen3.8-27b' },
|
||||
{ id: 'text-embedding-nomic-embed-text-v1.5' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(svc.listModels()).resolves.toEqual([
|
||||
'qwen3.8-27b',
|
||||
'text-embedding-nomic-embed-text-v1.5',
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/models`);
|
||||
});
|
||||
|
||||
it('chat posts model+messages and returns choices[0].message.content', async () => {
|
||||
const svc = makeService();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ choices: [{ message: { content: 'hello back' } }] }),
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: 'be brief' },
|
||||
{ role: 'user', content: 'hi' },
|
||||
];
|
||||
await expect(
|
||||
svc.chat(messages, { maxTokens: 50, temperature: 0.2 }),
|
||||
).resolves.toBe('hello back');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`${BASE}/chat/completions`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: 'qwen3.8-27b',
|
||||
messages,
|
||||
maxTokens: 50,
|
||||
temperature: 0.2,
|
||||
});
|
||||
});
|
||||
|
||||
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||
const svc = makeService();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'upstream down',
|
||||
} as Response);
|
||||
|
||||
await expect(svc.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
|
||||
/HTTP 503.*upstream down/,
|
||||
);
|
||||
});
|
||||
|
||||
it('summarize calls chat with the system prompt plus the user text', async () => {
|
||||
const svc = makeService();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ choices: [{ message: { content: 'the summary' } }] }),
|
||||
);
|
||||
|
||||
await expect(svc.summarize('a long text')).resolves.toBe('the summary');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).messages).toEqual([
|
||||
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||
{ role: 'user', content: 'a long text' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/** A single chat message in OpenAI chat-completions shape. */
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmService {
|
||||
// read at construction, like other services' env config
|
||||
private readonly apiBase =
|
||||
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
||||
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
||||
// local 27B model can be slow (uncontrollable reasoning tokens) — generous default: 3 min headroom for slow reasoning periods
|
||||
private readonly timeoutMs = parseInt(
|
||||
process.env.LLM_TIMEOUT_MS ?? '180000',
|
||||
10,
|
||||
);
|
||||
|
||||
/** GET {base}/models → the `data[].id` strings. */
|
||||
async listModels(): Promise<string[]> {
|
||||
const res = await fetch(`${this.apiBase}/models`, {
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`listModels failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
return (body.data ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
/** POST {base}/chat/completions → `choices[0].message.content`. */
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
opts?: { maxTokens?: number; temperature?: number },
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${this.apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: this.model, messages, ...opts }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`chat failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chat failed: no choices[0].message.content in response');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Thin wrapper over chat(): the stated primary use of this service. */
|
||||
async summarize(text: string): Promise<string> {
|
||||
return this.chat([
|
||||
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||
{ role: 'user', content: text },
|
||||
]);
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -1,5 +1,7 @@
|
||||
import { fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractFrames, frame } from './codec.js';
|
||||
import { buildTextToRadio, extractFrames, frame } from './codec.js';
|
||||
|
||||
const F = (payload: number[]) => [
|
||||
0x94,
|
||||
@@ -78,3 +80,25 @@ describe('frame', () => {
|
||||
expect(rest.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// re-serialize just the nested Data message so its wire bytes are inspectable without the random packet id interfering
|
||||
function dataBytes(framed: Uint8Array): Uint8Array {
|
||||
const { packets } = extractFrames(framed); // buildTextToRadio returns framed bytes
|
||||
expect(packets).toHaveLength(1);
|
||||
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
|
||||
if (tr.payloadVariant?.case !== 'packet') throw new Error('not a packet');
|
||||
const pv = tr.payloadVariant.value.payloadVariant;
|
||||
if (pv?.case !== 'decoded') throw new Error('not decoded');
|
||||
return toBinary(P.Mesh.DataSchema, pv.value);
|
||||
}
|
||||
|
||||
describe('buildTextToRadio', () => {
|
||||
it('writes wire tag 0x3d (Data.reply_id, field 7) iff replyId is given', async () => {
|
||||
const withReply = await buildTextToRadio(42, 'hi', 3, 42);
|
||||
expect(Array.from(dataBytes(withReply.bytes))).toContain(0x3d);
|
||||
|
||||
// omitting it must leave the field off the wire (proto3 default 0) — snake_case reply_id would be silently dropped here
|
||||
const plain = await buildTextToRadio(42, 'hi', 3);
|
||||
expect(Array.from(dataBytes(plain.bytes))).not.toContain(0x3d);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff;
|
||||
export const PORT_TEXT_MESSAGE_APP = 1;
|
||||
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
|
||||
export const PORT_POSITION_APP = 3;
|
||||
// verified against installed @meshtastic/protobufs Portnums.PortNum.NODEINFO_APP
|
||||
export const PORT_NODEINFO_APP = 4;
|
||||
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
|
||||
export const PORT_ROUTING_APP = 5;
|
||||
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
|
||||
@@ -75,6 +77,14 @@ export async function decodePosition(
|
||||
return fromBinary(P.Mesh.PositionSchema, bytes);
|
||||
}
|
||||
|
||||
/** NODEINFO_APP packets carry a serialized meshtastic.NodeInfo in Data.payload (live node info; deviceMetrics carries battery_level). */
|
||||
export async function decodeNodeInfo(
|
||||
bytes: Uint8Array,
|
||||
): Promise<import('@meshtastic/protobufs').Mesh.NodeInfo> {
|
||||
const P = await loadProto();
|
||||
return fromBinary(P.Mesh.NodeInfoSchema, bytes);
|
||||
}
|
||||
|
||||
/** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
|
||||
export async function decodeRouting(
|
||||
bytes: Uint8Array,
|
||||
@@ -126,6 +136,7 @@ export async function buildTextToRadio(
|
||||
to: number,
|
||||
text: string,
|
||||
channelIndex: number,
|
||||
replyId?: number,
|
||||
): Promise<{ bytes: Uint8Array; id: number }> {
|
||||
const P = await loadProto();
|
||||
const id = randomPacketId();
|
||||
@@ -140,6 +151,8 @@ export async function buildTextToRadio(
|
||||
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
||||
payload: new TextEncoder().encode(text),
|
||||
wantResponse: true,
|
||||
// camelCase on purpose — the JS protobufs package silently drops snake_case reply_id; 0 = not a reply (proto3 default, omitted from wire)
|
||||
replyId: replyId ?? 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+45
-2
@@ -1,8 +1,8 @@
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
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 { frame } from './codec.js';
|
||||
import { extractFrames, frame } from './codec.js';
|
||||
|
||||
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
|
||||
export function startBridge(
|
||||
@@ -148,6 +148,49 @@ export function telemetryPacketFrame(
|
||||
);
|
||||
}
|
||||
|
||||
/** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */
|
||||
export function textPacketFrame(
|
||||
from: number,
|
||||
to: number,
|
||||
text: string,
|
||||
channel = 0,
|
||||
): Uint8Array {
|
||||
const data = create(P.Mesh.DataSchema, {
|
||||
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
|
||||
payload: new TextEncoder().encode(text),
|
||||
});
|
||||
const pkt = create(P.Mesh.MeshPacketSchema, {
|
||||
from,
|
||||
to,
|
||||
id: 1,
|
||||
channel,
|
||||
payloadVariant: { case: 'decoded', value: data },
|
||||
});
|
||||
return frame(
|
||||
toBinary(
|
||||
P.Mesh.FromRadioSchema,
|
||||
create(P.Mesh.FromRadioSchema, {
|
||||
payloadVariant: { case: 'packet', value: pkt },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
nodeInfoFrame,
|
||||
startBridge,
|
||||
telemetryPacketFrame,
|
||||
textPacketFrame,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
import { createMeshMcpServer } from './mcp.controller.js';
|
||||
@@ -67,7 +68,7 @@ async function connectMcp(svc: MeshService): Promise<{
|
||||
}
|
||||
|
||||
describe('MCP server tools', () => {
|
||||
it('lists all nine tools with descriptions and input schemas', async () => {
|
||||
it('lists all ten tools with descriptions and input schemas', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
@@ -82,6 +83,7 @@ describe('MCP server tools', () => {
|
||||
'list_channels',
|
||||
'list_nodes',
|
||||
'mesh_status',
|
||||
'respond',
|
||||
'send_message',
|
||||
]);
|
||||
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
||||
@@ -91,6 +93,7 @@ describe('MCP server tools', () => {
|
||||
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
|
||||
'text',
|
||||
'to',
|
||||
'replyId',
|
||||
]);
|
||||
|
||||
const inject = tools.find((t) => t.name === 'inject_message')!;
|
||||
@@ -99,6 +102,13 @@ describe('MCP server tools', () => {
|
||||
'text',
|
||||
'from',
|
||||
]);
|
||||
|
||||
const respond = tools.find((t) => t.name === 'respond')!;
|
||||
expect(respond.inputSchema.required).toEqual(['messageId', 'content']);
|
||||
expect(Object.keys(respond.inputSchema.properties ?? {})).toEqual([
|
||||
'messageId',
|
||||
'content',
|
||||
]);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
@@ -195,6 +205,57 @@ describe('MCP server tools', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('respond routes the reply automatically and surfaces failures as tool errors', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
// rx DM from KAMIL on channel 3 (packet id 1)
|
||||
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
|
||||
await waitFor(() => env.svc.messages().length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'respond',
|
||||
arguments: { messageId: 1, content: 'pong' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
const reply = JSON.parse(toolText(r1)) as { id: number };
|
||||
expect(reply.id).toBeGreaterThan(0);
|
||||
// DM back to the original sender on the original channel
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
text: 'pong',
|
||||
channel: 3,
|
||||
});
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'respond',
|
||||
arguments: { messageId: 999, content: 'x' },
|
||||
});
|
||||
expect(r2.isError).toBe(true);
|
||||
expect(toolText(r2)).toContain('no message with id 999');
|
||||
|
||||
const r3 = await mcp.client.callTool({
|
||||
name: 'respond',
|
||||
arguments: { messageId: 0, content: 'x' },
|
||||
});
|
||||
expect(r3.isError).toBe(true); // out-of-range rejected before the service sees it
|
||||
|
||||
const r4 = await mcp.client.callTool({
|
||||
name: 'respond',
|
||||
arguments: { messageId: 1, content: '' },
|
||||
});
|
||||
expect(r4.isError).toBe(true);
|
||||
expect(toolText(r4)).toContain('content must be a non-empty string');
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('inject_message records an rx entry without sending over RF', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
|
||||
@@ -33,11 +33,30 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
|
||||
server.registerTool(
|
||||
'send_message',
|
||||
{
|
||||
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
|
||||
inputSchema: { text: z.string(), to: z.string().optional() },
|
||||
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error. replyId (optional, integer in 1..0xffffffff): the packet id of an earlier message from GET /api/messages or get_messages — when set, the recipient app renders this message as a threaded reply/quote of that packet.${CHANNEL_NOTE}`,
|
||||
inputSchema: {
|
||||
text: z.string(),
|
||||
to: z.string().optional(),
|
||||
replyId: z.number().int().min(1).max(0xffffffff).optional(),
|
||||
},
|
||||
async ({ text, to }) =>
|
||||
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
|
||||
},
|
||||
async ({ text, to, replyId }) =>
|
||||
jsonResult({
|
||||
id: await mesh.sendText(await mesh.resolveDest(to), text, replyId),
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'respond',
|
||||
{
|
||||
description: `Reply to an earlier message by its packet id (from get_messages); returns { id } — the Meshtastic packet id of the reply. Routing is automatic: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals and injected messages without a sender get a broadcast. The reply always rides the record's channel and carries replyId = messageId (threaded quote in the recipient app). Invalid messageId/content or an unknown id are tool errors.`,
|
||||
inputSchema: {
|
||||
messageId: z.number().int().min(1).max(0xffffffff),
|
||||
content: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ messageId, content }) =>
|
||||
jsonResult(await mesh.respond(messageId, content)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { BROADCAST_ADDR } from './codec.js';
|
||||
import type { DeviceInfo } from './mesh.service.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
@@ -28,6 +29,17 @@ export class InjectMessageDto {
|
||||
from?: number;
|
||||
}
|
||||
|
||||
export class RespondDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Packet id of the earlier message (from GET /api/messages) that this one replies to. Integer in 1..0xffffffff.',
|
||||
example: 456789,
|
||||
})
|
||||
messageId!: number;
|
||||
@ApiProperty({ description: 'Reply text', example: 'on my way' })
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class SendTextDto {
|
||||
@ApiProperty({ description: 'Message text', example: 'hello' })
|
||||
text!: string;
|
||||
@@ -38,6 +50,13 @@ export class SendTextDto {
|
||||
example: '!b',
|
||||
})
|
||||
to?: string | number;
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Packet id of an earlier message (from GET /api/messages) that this one replies to — the recipient app renders it as a threaded reply/quote. Integer in 1..0xffffffff.',
|
||||
example: 456789,
|
||||
})
|
||||
replyId?: number;
|
||||
}
|
||||
|
||||
// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description)
|
||||
@@ -70,7 +89,7 @@ export class MeshController {
|
||||
@ApiOperation({
|
||||
summary: 'Send a text message over the mesh',
|
||||
description:
|
||||
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
|
||||
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). Optional replyId (integer in 1..0xffffffff) references an earlier message\'s packet id from GET /api/messages — when set, the recipient app renders this message as a threaded reply/quote of that packet. DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -83,6 +102,15 @@ export class MeshController {
|
||||
if (typeof body?.text !== 'string' || body.text.length === 0) {
|
||||
throw new BadRequestException('text must be a non-empty string');
|
||||
}
|
||||
const replyId = body.replyId;
|
||||
if (
|
||||
replyId !== undefined &&
|
||||
(!Number.isInteger(replyId) || replyId < 1 || replyId > BROADCAST_ADDR)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'replyId must be an integer in 1..0xffffffff',
|
||||
);
|
||||
}
|
||||
let to: number;
|
||||
try {
|
||||
to = await this.mesh.resolveDest(body.to);
|
||||
@@ -91,7 +119,25 @@ export class MeshController {
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
return { id: await this.mesh.sendText(to, body.text) };
|
||||
return { id: await this.mesh.sendText(to, body.text, replyId) };
|
||||
}
|
||||
|
||||
@Post('respond')
|
||||
@ApiOperation({
|
||||
summary: 'Reply to an earlier message by packet id',
|
||||
description:
|
||||
"Routing is automatic — the caller never thinks about where it goes: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals (and injected messages without a sender) get a broadcast. The reply always rides the record's channel and carries replyId = messageId, so the recipient app renders it as a threaded quote of that packet. Returns { id } with the new packet id.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Packet queued',
|
||||
example: { id: 123456789 },
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid messageId or content' })
|
||||
@ApiResponse({ status: 404, description: 'No message with the given id' })
|
||||
@ApiResponse({ status: 503, description: 'Bridge not connected' })
|
||||
async respond(@Body() body: RespondDto) {
|
||||
return this.mesh.respond(body?.messageId, body?.content);
|
||||
}
|
||||
|
||||
@Get('channels')
|
||||
@@ -195,6 +241,29 @@ export class MeshController {
|
||||
return this.mesh.messages();
|
||||
}
|
||||
|
||||
@Get('forwarded')
|
||||
@ApiOperation({
|
||||
summary: 'Forwarded message history (last 100)',
|
||||
description:
|
||||
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Forwarded messages newest first',
|
||||
example: [
|
||||
{
|
||||
id: 456789,
|
||||
from: 987654321,
|
||||
text: 'ping',
|
||||
channel: 3,
|
||||
at: 1758000000000,
|
||||
},
|
||||
],
|
||||
})
|
||||
forwarded() {
|
||||
return this.mesh.forwarded();
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
@ApiOperation({
|
||||
summary: 'Inject a synthetic inbound message into the history',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import { McpController } from './mcp.controller.js';
|
||||
import { MeshController } from './mesh.controller.js';
|
||||
import { initMessagesTable, MeshService } from './mesh.service.js';
|
||||
@@ -18,10 +19,14 @@ function openMessageDb(): DatabaseSync {
|
||||
@Module({
|
||||
controllers: [MeshController, McpController],
|
||||
providers: [
|
||||
OpencodeService,
|
||||
{
|
||||
provide: MeshService,
|
||||
useFactory: () => new MeshService(openMessageDb()),
|
||||
useFactory: (opencode: OpencodeService) =>
|
||||
new MeshService(openMessageDb(), opencode),
|
||||
inject: [OpencodeService],
|
||||
},
|
||||
],
|
||||
exports: [MeshService],
|
||||
})
|
||||
export class MeshModule {}
|
||||
|
||||
+857
-125
File diff suppressed because it is too large
Load Diff
+249
-41
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
@@ -9,8 +11,12 @@ import * as net from 'node:net';
|
||||
// type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests)
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type * as Proto from '@meshtastic/protobufs';
|
||||
// type-only: the instance arrives via attachCommands() in production (command.module.ts) or the constructor in tests — no runtime import cycle with src/commands
|
||||
import type { CommandService } from '../commands/command.service.js';
|
||||
import { OpencodeService } from '../opencode/opencode.service.js';
|
||||
import {
|
||||
BROADCAST_ADDR,
|
||||
PORT_NODEINFO_APP,
|
||||
PORT_POSITION_APP,
|
||||
PORT_ROUTING_APP,
|
||||
PORT_TELEMETRY_APP,
|
||||
@@ -18,6 +24,7 @@ import {
|
||||
buildTextToRadio,
|
||||
buildWantConfigToRadio,
|
||||
decodeFromRadio,
|
||||
decodeNodeInfo,
|
||||
decodePosition,
|
||||
decodeRouting,
|
||||
decodeTelemetry,
|
||||
@@ -34,7 +41,7 @@ export interface MessageRecord {
|
||||
dir: 'rx' | 'tx';
|
||||
/** sender node num — rx only */
|
||||
from?: number;
|
||||
/** destination node num (broadcast addr for broadcasts) — tx only */
|
||||
/** destination node num (broadcast addr for broadcasts) — captured rx packets and all tx records */
|
||||
to?: number;
|
||||
text: string;
|
||||
channel: number;
|
||||
@@ -46,6 +53,18 @@ export interface MessageRecord {
|
||||
ackError?: string;
|
||||
}
|
||||
|
||||
/** One entry in the forwarded ring: an rx text that passed the notify gate and was handed off to opencode. */
|
||||
export interface ForwardedRecord {
|
||||
/** packet id (real rx) or generated record id (injected) */
|
||||
id: number;
|
||||
/** sender node num — omitted when there is no sender */
|
||||
from?: number;
|
||||
text: string;
|
||||
channel: number;
|
||||
/** epoch ms */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
|
||||
export interface TelemetryRecord {
|
||||
/** sender node num */
|
||||
@@ -62,6 +81,8 @@ export interface TelemetryRecord {
|
||||
const HISTORY_LIMIT = 100;
|
||||
/** Rows kept in SQLite across restarts; older rows are pruned on every poller tick. */
|
||||
const DB_RETENTION = 1000;
|
||||
// how often the idle watchdog checks for a silent-but-connected bridge (ms)
|
||||
const IDLE_CHECK_MS = 30_000;
|
||||
|
||||
// one row per MessageRecord, keyed by packet id (tx ids and injected ids are random uint32, rx uses the wire id)
|
||||
export const MESSAGES_DDL = `CREATE TABLE IF NOT EXISTS messages(
|
||||
@@ -81,6 +102,22 @@ export function initMessagesTable(db: DatabaseSync): void {
|
||||
db.exec(MESSAGES_DDL);
|
||||
}
|
||||
|
||||
/** Map a messages-table row to a MessageRecord (NULLs → omitted optionals). */
|
||||
function recordFromRow(row: Record<string, unknown>): MessageRecord {
|
||||
const rec: MessageRecord = {
|
||||
id: Number(row.id),
|
||||
dir: row.dir === 'tx' ? 'tx' : 'rx',
|
||||
text: String(row.text),
|
||||
channel: Number(row.channel),
|
||||
at: Number(row.at),
|
||||
};
|
||||
if (row.to_node != null) rec.to = Number(row.to_node);
|
||||
if (row.from_node != null) rec.from = Number(row.from_node);
|
||||
if (row.status != null) rec.status = row.status as MessageRecord['status'];
|
||||
if (row.ack_error != null) rec.ackError = String(row.ack_error);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
|
||||
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
||||
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
||||
@@ -128,6 +165,15 @@ function jsonSafe<T>(value: T): T {
|
||||
return value; // primitives + undefined pass through
|
||||
}
|
||||
|
||||
/** MESH_NOTIFY_CHANNEL is a comma-separated list of channel indices; empty/invalid entries are ignored. */
|
||||
function parseNotifyChannels(raw: string): number[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /^\d+$/.test(s))
|
||||
.map((s) => parseInt(s, 10));
|
||||
}
|
||||
|
||||
// key material must never leave the process — replaced with a marker so callers can see the field exists
|
||||
const REDACTED = 'REDACTED';
|
||||
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
|
||||
@@ -157,21 +203,50 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// outbound notification webhook: fire-and-forget POST when an rx text lands on the notify channel; empty URL = disabled
|
||||
private readonly notifyUrl = process.env.MESH_NOTIFY_URL ?? '';
|
||||
private readonly notifyChannel = parseInt(
|
||||
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on one of the notify channels;
|
||||
// disabled state (missing credentials) lives in OpencodeService itself
|
||||
private readonly notifyChannels = parseNotifyChannels(
|
||||
process.env.MESH_NOTIFY_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// how often the poller re-upserts the history buffer into SQLite (ms)
|
||||
private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10);
|
||||
// idle watchdog: force a reconnect when an ESTABLISHED connection stops decoding frames — the zombie-bridge case where outbound sends keep working but inbound frame flow is dead (raw non-frame bytes do NOT reset the clock). Read at construction like the other env config.
|
||||
private readonly idleTimeoutMs = parseInt(
|
||||
process.env.MESH_IDLE_TIMEOUT_MS ?? '60000',
|
||||
10,
|
||||
);
|
||||
// lazy periodic autoreconnect: unconditionally destroy + re-establish every N ms so a fresh sync happens on schedule — the bridge/radio side tends to kill inbound RX within ~20 min of a fresh sync. Read at construction like the other env config.
|
||||
private readonly reconnectIntervalMs = parseInt(
|
||||
process.env.MESH_RECONNECT_INTERVAL_MS ?? '300000',
|
||||
10,
|
||||
);
|
||||
// cap on undecoded frame buffer: a desynced stream that never forms frames would grow it without bound — force a reconnect instead. Env-overridable so tests can trigger it (Node caps socket 'data' events at ~64 KB).
|
||||
private readonly maxFrameBuffer = parseInt(
|
||||
process.env.MESH_MAX_FRAME_BUFFER ?? '256000',
|
||||
10,
|
||||
);
|
||||
|
||||
/** SQLite database backing the message history; null = persistence disabled (tests construct without one). */
|
||||
private db: DatabaseSync | null = null;
|
||||
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
||||
private readonly opencode: OpencodeService | null;
|
||||
/** Event-driven `#command` auto-responder (COMMAND_CHANNEL); null until wired — via the constructor (tests) or attachCommands() (DI). */
|
||||
private commands: CommandService | null = null;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(db?: DatabaseSync) {
|
||||
constructor(
|
||||
db?: DatabaseSync,
|
||||
opencode?: OpencodeService,
|
||||
commands?: CommandService,
|
||||
) {
|
||||
this.db = db ?? null;
|
||||
this.opencode = opencode ?? null;
|
||||
this.commands = commands ?? null;
|
||||
}
|
||||
|
||||
/** Wire the `#command` auto-responder after construction — used by command.module.ts to break the DI cycle (a constructor-level MeshService <-> CommandService cycle leaves one side null in NestJS's injector). */
|
||||
attachCommands(commands: CommandService): void {
|
||||
this.commands = commands;
|
||||
}
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
@@ -191,10 +266,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
||||
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
||||
private history: MessageRecord[] = [];
|
||||
// rx texts handed off to the opencode notifier (newest first, capped at HISTORY_LIMIT); in-memory only
|
||||
private forwardedHistory: ForwardedRecord[] = [];
|
||||
private pendingTx = new Map<number, MessageRecord>();
|
||||
// telemetry history (newest kept, capped at HISTORY_LIMIT)
|
||||
private telemetryHistory: TelemetryRecord[] = [];
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
/** epoch ms when the last valid frame was decoded from the bridge; reset on connect — idle watchdog baseline */
|
||||
private lastPacketAt = Date.now();
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
private periodicReconnectTimer: NodeJS.Timeout | null = null;
|
||||
private shuttingDown = false;
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -204,6 +285,15 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
// ponytail: full re-upsert of the ≤100-row buffer every tick — trivial load, zero bookkeeping
|
||||
this.pollTimer = setInterval(() => this.flushHistory(), this.pollMs);
|
||||
}
|
||||
// idle watchdog: catches established-but-silent connections (zombie bridge) that the connect/retry loop never sees
|
||||
this.idleTimer = setInterval(() => this.checkIdle(), IDLE_CHECK_MS);
|
||||
// lazy periodic autoreconnect: unconditional fresh sync on a fixed schedule; when not connected, destroy is a no-op and the retry loop owns recovery
|
||||
this.periodicReconnectTimer = setInterval(() => {
|
||||
this.logger.log(
|
||||
'periodic reconnect — MESH_RECONNECT_INTERVAL_MS elapsed',
|
||||
);
|
||||
this.socket?.destroy();
|
||||
}, this.reconnectIntervalMs);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
@@ -211,6 +301,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.shuttingDown = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
if (this.idleTimer) clearInterval(this.idleTimer);
|
||||
if (this.periodicReconnectTimer) clearInterval(this.periodicReconnectTimer);
|
||||
this.socket?.destroy();
|
||||
this.db?.close();
|
||||
}
|
||||
@@ -352,6 +444,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
return [...this.history].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* The last HISTORY_LIMIT rx texts that passed the notify gate and were handed off to opencode, newest first:
|
||||
* channel matched MESH_NOTIFY_CHANNEL, not a self-echo, and a notifier is wired. In-memory only — reset on restart.
|
||||
*/
|
||||
forwarded(): ForwardedRecord[] {
|
||||
return [...this.forwardedHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-upsert the entire in-memory history buffer into SQLite (INSERT OR REPLACE per row) and enforce
|
||||
* retention: only the newest DB_RETENTION rows survive. Called by the poller every MESH_POLL_MS;
|
||||
@@ -387,21 +487,55 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages ORDER BY at DESC LIMIT ?',
|
||||
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
|
||||
// rows come back newest first — the buffer is oldest-first
|
||||
for (const row of [...rows].reverse()) {
|
||||
const rec: MessageRecord = {
|
||||
id: Number(row.id),
|
||||
dir: row.dir === 'tx' ? 'tx' : 'rx',
|
||||
text: String(row.text),
|
||||
channel: Number(row.channel),
|
||||
at: Number(row.at),
|
||||
};
|
||||
if (row.to_node != null) rec.to = Number(row.to_node);
|
||||
if (row.from_node != null) rec.from = Number(row.from_node);
|
||||
if (row.status != null)
|
||||
rec.status = row.status as MessageRecord['status'];
|
||||
if (row.ack_error != null) rec.ackError = String(row.ack_error);
|
||||
this.history.push(rec);
|
||||
for (const row of [...rows].reverse())
|
||||
this.history.push(recordFromRow(row));
|
||||
}
|
||||
|
||||
/** Look up a history record by packet id: in-memory buffer first, then SQLite (newest 1000). */
|
||||
private findRecord(id: number): MessageRecord | undefined {
|
||||
const hit = this.history.find((r) => r.id === id);
|
||||
if (hit) return hit;
|
||||
const db = this.db;
|
||||
if (!db) return undefined;
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages WHERE id = ?',
|
||||
)
|
||||
.get(id) as Record<string, unknown> | undefined;
|
||||
return row ? recordFromRow(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to an earlier message by packet id — the caller never thinks about where it goes:
|
||||
* rx records are answered as a DM back to the original sender, tx records go to their destination;
|
||||
* broadcast originals (and injected messages without a sender) get a broadcast. The reply always
|
||||
* rides the record's channel and carries replyId = messageId, so the recipient app renders it as a
|
||||
* threaded quote of that packet. Returns { id } with the new packet id. Throws BadRequest on invalid
|
||||
* arguments, NotFound when no message has the given id (in memory or in SQLite), 503 when the bridge is down.
|
||||
*/
|
||||
async respond(messageId: number, content: string): Promise<{ id: number }> {
|
||||
if (
|
||||
!Number.isInteger(messageId) ||
|
||||
messageId < 1 ||
|
||||
messageId > BROADCAST_ADDR
|
||||
)
|
||||
throw new BadRequestException(
|
||||
'messageId must be an integer in 1..0xffffffff',
|
||||
);
|
||||
if (typeof content !== 'string' || content.length === 0)
|
||||
throw new BadRequestException('content must be a non-empty string');
|
||||
const rec = this.findRecord(messageId);
|
||||
if (!rec) throw new NotFoundException(`no message with id ${messageId}`);
|
||||
// rx → DM back to the sender unless the original was a broadcast (then reply as a broadcast — that's what the stored destination is for);
|
||||
// tx → its own destination; injected records without a sender → broadcast
|
||||
const target =
|
||||
rec.dir === 'rx'
|
||||
? rec.to === BROADCAST_ADDR
|
||||
? BROADCAST_ADDR
|
||||
: rec.from
|
||||
: rec.to;
|
||||
const to = target ?? BROADCAST_ADDR;
|
||||
return { id: await this.sendText(to, content, messageId, rec.channel) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,29 +560,42 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
at: Date.now(),
|
||||
};
|
||||
this.pushHistory(rec);
|
||||
// injected records ride channel 0 — the webhook fires only when MESH_NOTIFY_CHANNEL is set to match
|
||||
this.notifyRx(0, text, from);
|
||||
// injected records ride channel 0 — the notifier fires only when 0 is in MESH_NOTIFY_CHANNEL (injections are exempt from the ch0 DM-only rule)
|
||||
this.notifyRx(0, text, from, rec.id, undefined, true);
|
||||
return { id: rec.id };
|
||||
}
|
||||
|
||||
/** Fire-and-forget notify-webhook POST for an rx text on `channel` (no-op unless URL is set and channels match). */
|
||||
private notifyRx(channel: number, text: string, from?: number): void {
|
||||
if (this.notifyUrl && channel === this.notifyChannel) {
|
||||
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless the channel is in the notify list and a notifier is wired). Both call sites pass the packet id; `injected` marks synthetic records that ride ch0 by design. */
|
||||
private notifyRx(
|
||||
channel: number,
|
||||
text: string,
|
||||
from?: number,
|
||||
id?: number,
|
||||
to?: number,
|
||||
injected = false,
|
||||
): void {
|
||||
if (!this.opencode || !this.notifyChannels.includes(channel)) return;
|
||||
// ch0 is the shared default channel where other users talk — only directed DMs (a real destination) are notified there; broadcasts and undirected traffic are noise. Injected records ride ch0 by design, so they're exempt.
|
||||
if (
|
||||
channel === 0 &&
|
||||
!injected &&
|
||||
(to === undefined || to === BROADCAST_ADDR)
|
||||
)
|
||||
return;
|
||||
// self-echo filter: our own node must never trigger a notification of itself
|
||||
if (from !== undefined && from === this.myNodeNum) {
|
||||
this.logger.debug(`notify skipped: rx from our own node ${from}`);
|
||||
return;
|
||||
}
|
||||
const body: Record<string, unknown> = { text };
|
||||
if (from !== undefined) body.from = from;
|
||||
void fetch(this.notifyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch((err: unknown) =>
|
||||
this.logger.warn('notify webhook failed: ' + (err as Error).message),
|
||||
);
|
||||
}
|
||||
// record the forward at hand-off — the ring holds exactly what a wired notifier receives (newest first).
|
||||
// Pinned semantics: an unwired notifier records nothing (early return above); a notifier disabled internally
|
||||
// (empty credentials) still counts — MeshService cannot see its enabled flag.
|
||||
const entry: ForwardedRecord = { id: id!, text, channel, at: Date.now() };
|
||||
if (from !== undefined) entry.from = from;
|
||||
this.forwardedHistory.unshift(entry);
|
||||
if (this.forwardedHistory.length > HISTORY_LIMIT)
|
||||
this.forwardedHistory.pop(); // oldest evicted
|
||||
void this.opencode.notify(id!, text, from); // both call sites always pass the packet id
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,17 +608,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
return [...this.telemetryHistory].reverse();
|
||||
}
|
||||
|
||||
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
|
||||
async sendText(to: number, text: string): Promise<number> {
|
||||
/** Send `text` to node `to`; returns the packet id. Rides the pinned test channel unless an explicit `channel` index is given (e.g. battery alerts on "testowy"). Optional `replyId` marks the message as a reply to an earlier packet (the recipient app renders it as a threaded quote). Throws ServiceUnavailable when the bridge is down. */
|
||||
async sendText(
|
||||
to: number,
|
||||
text: string,
|
||||
replyId?: number,
|
||||
channel?: number,
|
||||
): Promise<number> {
|
||||
if (!this.connected || !this.socket)
|
||||
throw new ServiceUnavailableException('bridge not connected');
|
||||
const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
|
||||
const ch = channel ?? this.sendChannel;
|
||||
const { bytes, id } = await buildTextToRadio(to, text, ch, replyId);
|
||||
const rec: MessageRecord = {
|
||||
id,
|
||||
dir: 'tx',
|
||||
to,
|
||||
text,
|
||||
channel: this.sendChannel,
|
||||
channel: ch,
|
||||
at: Date.now(),
|
||||
status: 'pending',
|
||||
};
|
||||
@@ -520,11 +673,17 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private connect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
const socket = net.createConnection({ host: this.host, port: this.port });
|
||||
// TCP-level keepalive: cheap insurance against a dead peer that the idle watchdog (application level) complements
|
||||
socket.setKeepAlive(true, 30_000);
|
||||
this.socket = socket;
|
||||
socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
// fresh connection — reset the idle clock so it can't self-trigger before any traffic arrives
|
||||
this.lastPacketAt = Date.now();
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes/device info/telemetry from the old session;
|
||||
// message history is NOT reset: it's an append-only log backed by SQLite and survives reconnects
|
||||
// message history is NOT reset: it's an append-only log backed by SQLite and survives reconnects.
|
||||
// The undecoded frame buffer goes too: leftover desync garbage must not re-trigger the overflow backstop on a healthy new stream.
|
||||
this.buffer = new Uint8Array(0);
|
||||
this.channelMap = new Map();
|
||||
this.nodeMap = new Map();
|
||||
this.metadata = null;
|
||||
@@ -545,9 +704,19 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
||||
merged.set(this.buffer, 0);
|
||||
merged.set(chunk, this.buffer.length);
|
||||
// buffer-overflow backstop: a desynced stream that never forms frames would grow the buffer without bound — cap it and self-heal via reconnect
|
||||
if (merged.length > this.maxFrameBuffer) {
|
||||
this.logger.error(
|
||||
'frame buffer overflow — stream desync, forcing reconnect',
|
||||
);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
this.buffer = merged;
|
||||
const { packets, rest } = extractFrames(this.buffer);
|
||||
this.buffer = rest;
|
||||
// the watchdog tracks DECODED FRAMES, not raw bytes: non-frame noise (keepalives, desynced streams) must not keep a dead connection alive
|
||||
if (packets.length) this.lastPacketAt = Date.now();
|
||||
for (const p of packets) {
|
||||
decodeFromRadio(p)
|
||||
.then((fr) => this.handleFromRadio(fr))
|
||||
@@ -573,6 +742,25 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle watchdog tick: force a reconnect when an ESTABLISHED connection has decoded no frames from
|
||||
* the bridge for MESH_IDLE_TIMEOUT_MS — the zombie case where outbound sends keep working but inbound
|
||||
* frame flow is dead (raw non-frame bytes arriving on the socket do not count). Destroying the socket
|
||||
* routes through 'close' → onDown → scheduleReconnect, so the existing connect/retry loop re-establishes
|
||||
* with a fresh sync; no new retry machinery here. Public so tests can drive a tick without waiting for
|
||||
* the real interval (like flushHistory()).
|
||||
*/
|
||||
checkIdle(): void {
|
||||
if (!this.connected) return; // disconnected state is owned by the connect/retry loop
|
||||
const idle = Date.now() - this.lastPacketAt;
|
||||
if (idle > this.idleTimeoutMs) {
|
||||
this.logger.warn(
|
||||
`no decoded bridge frames for ${idle} ms — forcing reconnect`,
|
||||
);
|
||||
this.socket?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFromRadio(
|
||||
fr: import('@meshtastic/protobufs').Mesh.FromRadio,
|
||||
): Promise<void> {
|
||||
@@ -623,11 +811,14 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
id: pkt.id,
|
||||
dir: 'rx',
|
||||
from: pkt.from,
|
||||
to: pkt.to, // destination (broadcast addr for broadcasts) — lets a later reply tell DM vs broadcast apart
|
||||
text,
|
||||
channel: pkt.channel ?? 0,
|
||||
at: Date.now(),
|
||||
});
|
||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from);
|
||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
|
||||
// event-driven #command auto-responder (COMMAND_CHANNEL) — fire-and-forget like the notifier
|
||||
void this.commands?.onRx(pkt.channel ?? 0, text, pkt.from, pkt.id);
|
||||
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||
// live position update: the Position is serialized in Data.payload
|
||||
let pos: Proto.Mesh.Position | undefined;
|
||||
@@ -644,6 +835,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
} else if (decoded && decoded.portnum === PORT_NODEINFO_APP) {
|
||||
// live node-info update: NodeInfo serialized in Data.payload; deviceMetrics carries
|
||||
// battery_level so /api/nodes and the battery alerts see fresh values between syncs
|
||||
let ni: Proto.Mesh.NodeInfo | undefined;
|
||||
try {
|
||||
ni = await decodeNodeInfo(decoded.payload);
|
||||
} catch {
|
||||
ni = undefined; // malformed payload — skip, keep whatever we had
|
||||
}
|
||||
if (ni?.deviceMetrics !== undefined) {
|
||||
const num = pkt.from;
|
||||
this.nodeMap.set(num, {
|
||||
...this.nodeMap.get(num),
|
||||
num,
|
||||
deviceMetrics: ni.deviceMetrics,
|
||||
});
|
||||
}
|
||||
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
|
||||
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
|
||||
// Data.requestId = the original packet id we sent
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OpencodeService } from './opencode.service.js';
|
||||
|
||||
const API = 'http://172.24.0.1:9898';
|
||||
const WS_PATH = '/home/krzyw/projects/sandbox/mesh-bot';
|
||||
// the workspace UUID is regenerated on host restarts — only path/name/proxyPath matter
|
||||
const WORKSPACE = {
|
||||
id: 'ws-uuid',
|
||||
path: WS_PATH,
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/abc',
|
||||
};
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => body } as Response;
|
||||
}
|
||||
|
||||
function timeoutError(): Error {
|
||||
return new DOMException('signal timed out', 'TimeoutError');
|
||||
}
|
||||
|
||||
/** Fresh service with a clean env (creds unset by default — pass them explicitly). */
|
||||
function makeService(env: Record<string, string> = {}): OpencodeService {
|
||||
process.env.OPENCODE_API = API;
|
||||
process.env.OPENCODE_WORKSPACE_PATH = WS_PATH;
|
||||
for (const k of ['OPENCODE_SESSION', 'OPENCODE_USER', 'OPENCODE_PASS'])
|
||||
delete process.env[k];
|
||||
Object.assign(process.env, env);
|
||||
return new OpencodeService();
|
||||
}
|
||||
|
||||
function stubSleep(svc: OpencodeService): ReturnType<typeof vi.fn> {
|
||||
const sleep = vi.fn(async (_ms: number) => {});
|
||||
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
||||
return sleep;
|
||||
}
|
||||
|
||||
describe('OpencodeService.notify', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
for (const k of [
|
||||
'OPENCODE_API',
|
||||
'OPENCODE_WORKSPACE_PATH',
|
||||
'OPENCODE_SESSION',
|
||||
'OPENCODE_USER',
|
||||
'OPENCODE_PASS',
|
||||
])
|
||||
delete process.env[k];
|
||||
});
|
||||
|
||||
it('posts the exact wrapper text with `from` (em-dash) to the resolved session', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi', 42);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [url, init] = fetchMock.mock.calls[1];
|
||||
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
|
||||
const opts = init as {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers?.['content-type']).toBe('application/json');
|
||||
expect(opts.headers?.authorization).toBe(
|
||||
'Basic ' + Buffer.from('u:p').toString('base64'),
|
||||
);
|
||||
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
|
||||
expect(String(opts.body)).toBe(
|
||||
JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the `from node` part when there is no sender', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[1];
|
||||
expect(String((init as { body?: string }).body)).toBe(
|
||||
JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'New mesh message (id 7): "hi" — please react to it.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the workspace by path, preferring ready entries when several match', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json([
|
||||
{
|
||||
...WORKSPACE,
|
||||
id: 'other',
|
||||
status: 'starting',
|
||||
proxyPath: '/proxy/not-ready',
|
||||
}, // same path but not ready — listed first
|
||||
WORKSPACE,
|
||||
{
|
||||
id: 'x',
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/other',
|
||||
}, // different path — must lose to the path match
|
||||
]),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the workspace name when no path matches', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json([
|
||||
{
|
||||
id: 'a',
|
||||
path: '/elsewhere',
|
||||
name: 'other',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/a',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
path: '/renamed',
|
||||
name: 'mesh-bot',
|
||||
status: 'ready',
|
||||
proxyPath: '/proxy/b',
|
||||
},
|
||||
]),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/b/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers the most recently updated session when none is pinned', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(
|
||||
json([
|
||||
{ id: 'ses_old', time: { updated: 100 } },
|
||||
{ id: 'ses_new', time: { updated: 200 } },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_new/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the first session when none carry time.updated', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_a/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips session discovery entirely when a session is pinned', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockResolvedValueOnce(json({}));
|
||||
|
||||
await svc.notify(7, 'hi');
|
||||
|
||||
// workspaces GET + message POST only — no /session discovery GET
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
`${API}/proxy/abc/session/ses_pinned/message`,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a final-POST read timeout as delivered success and does not retry', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
|
||||
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||
|
||||
// exactly 2 calls: no retry after a timeout-as-delivered
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
||||
'opencode notify accepted (response still pending after 15 s)',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a failed workspace GET and succeeds on the next attempt', async () => {
|
||||
const svc = makeService({
|
||||
OPENCODE_USER: 'u',
|
||||
OPENCODE_PASS: 'p',
|
||||
OPENCODE_SESSION: 'ses_pinned',
|
||||
});
|
||||
const sleep = stubSleep(svc);
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('boom')) // attempt 1: workspaces GET fails
|
||||
.mockResolvedValueOnce(json([WORKSPACE])) // attempt 2: workspaces ok
|
||||
.mockResolvedValueOnce(json({})); // message POST ok
|
||||
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
||||
});
|
||||
|
||||
it('gives up after 4 attempts and logs opencode notify dropped', async () => {
|
||||
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||
const sleep = stubSleep(svc);
|
||||
fetchMock.mockRejectedValue(new Error('down'));
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
await expect(svc.notify('hi')).resolves.toBeUndefined(); // never throws — fire-and-forget safe
|
||||
|
||||
// one workspaces GET per attempt: exactly 4 attempts total
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
||||
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
|
||||
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
|
||||
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
|
||||
// the GET failure is wrapped with the URL for a clear reason — match prefix + cause
|
||||
expect(
|
||||
warns.some(
|
||||
(w) =>
|
||||
w.startsWith('opencode notify dropped after 4 attempts:') &&
|
||||
w.includes(': down'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op without credentials (empty or unset)', async () => {
|
||||
for (const env of [{}, { OPENCODE_USER: '', OPENCODE_PASS: 'p' }] as Record<
|
||||
string,
|
||||
string
|
||||
>[]) {
|
||||
const svc = makeService(env);
|
||||
await expect(svc.notify(7, 'hi', 42)).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs once at construction when disabled', () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||
makeService({}); // no creds
|
||||
expect(warnSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
||||
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
|
||||
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
||||
// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
||||
interface WorkspaceEntry {
|
||||
id?: string;
|
||||
path?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
proxyPath?: string;
|
||||
}
|
||||
|
||||
/** Entry of GET {base}/session (only the fields we use). */
|
||||
interface SessionEntry {
|
||||
id: string;
|
||||
time?: { updated?: number };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpencodeService {
|
||||
private readonly logger = new Logger(OpencodeService.name);
|
||||
// read at construction, like MeshService's env config
|
||||
private readonly api = process.env.OPENCODE_API ?? 'http://172.0.0.1:9898';
|
||||
private readonly workspacePath =
|
||||
process.env.OPENCODE_WORKSPACE_PATH ??
|
||||
'/home/krzyw/projects/sandbox/mesh-bot';
|
||||
// when set, skips session discovery
|
||||
private readonly sessionOverride = process.env.OPENCODE_SESSION ?? '';
|
||||
private readonly user = process.env.OPENCODE_USER ?? '';
|
||||
private readonly pass = process.env.OPENCODE_PASS ?? '';
|
||||
/** Notifier disabled without credentials — the app must keep running (and notify nothing) when they are missing. */
|
||||
private readonly enabled = this.user !== '' && this.pass !== '';
|
||||
|
||||
constructor() {
|
||||
if (!this.enabled)
|
||||
this.logger.warn(
|
||||
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered —
|
||||
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
|
||||
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
|
||||
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
||||
* after N attempts`. No-op when disabled (no credentials).
|
||||
*/
|
||||
async notify(id: number, text: string, from?: number): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const msg =
|
||||
`New mesh message (id ${id})` +
|
||||
(from != null ? ` from node ${from}` : '') +
|
||||
`: "${text}" — please react to it.`;
|
||||
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const { base, sid } = await this.resolveTarget();
|
||||
await this.postMessage(base, sid, msg);
|
||||
return; // success on any attempt — a POST timeout already resolved as delivered inside postMessage
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (attempt === maxAttempts) {
|
||||
this.logger.warn(
|
||||
`opencode notify dropped after ${attempt} attempts: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`opencode notify failed (attempt ${attempt}/${maxAttempts}): ${message}`,
|
||||
);
|
||||
await this.sleep(RETRY_DELAYS_MS[attempt - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Injectable so specs can skip the backoff delays. */
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return {
|
||||
authorization:
|
||||
'Basic ' + Buffer.from(`${this.user}:${this.pass}`).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve (base_url, session_id) fresh per request; throws with a clear reason on API/workspace/session failure. */
|
||||
private async resolveTarget(): Promise<{ base: string; sid: string }> {
|
||||
const workspaces = await this.getJson<WorkspaceEntry[]>(
|
||||
`${this.api}/api/workspaces`,
|
||||
);
|
||||
// match by path first, fall back to name — the workspace UUID is regenerated on host restarts
|
||||
const matches =
|
||||
workspaces.filter((w) => w.path === this.workspacePath).length > 0
|
||||
? workspaces.filter((w) => w.path === this.workspacePath)
|
||||
: workspaces.filter((w) => w.name === 'mesh-bot');
|
||||
// prefer ready entries when several match
|
||||
const pool =
|
||||
matches.filter((w) => w.status === 'ready').length > 0
|
||||
? matches.filter((w) => w.status === 'ready')
|
||||
: matches;
|
||||
if (pool.length === 0)
|
||||
throw new Error(
|
||||
`workspace not found: path=${this.workspacePath} name=mesh-bot`,
|
||||
);
|
||||
const ws = pool[0];
|
||||
const base = this.api + (ws.proxyPath ?? '');
|
||||
let sid = this.sessionOverride;
|
||||
if (!sid) {
|
||||
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`);
|
||||
if (sessions.length === 0)
|
||||
throw new Error(
|
||||
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
|
||||
);
|
||||
// most recently updated session; first entry when none carry time.updated
|
||||
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
|
||||
sid = sessions.some((s) => updated(s))
|
||||
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
||||
: sessions[0].id;
|
||||
}
|
||||
return { base, sid };
|
||||
}
|
||||
|
||||
private async getJson<T>(url: string): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: this.authHeaders(),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`opencode API unreachable at ${url}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new Error(`opencode API HTTP ${res.status} at ${url}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
private async postMessage(
|
||||
base: string,
|
||||
sid: string,
|
||||
msg: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${base}/session/${sid}/message`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
||||
} catch (err) {
|
||||
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
|
||||
// the response until the agent turn finishes; never retried
|
||||
if ((err as Error).name === 'TimeoutError') {
|
||||
this.logger.log(
|
||||
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
import { WeatherModule } from './weather.module.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
describe('DI wiring (module graph)', () => {
|
||||
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||
it('resolves WeatherService with MeshService provided by MeshModule', async () => {
|
||||
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [WeatherModule],
|
||||
}).compile();
|
||||
expect(moduleRef.get(WeatherService)).toBeInstanceOf(WeatherService);
|
||||
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { MeshModule } from '../mesh/mesh.module.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), MeshModule],
|
||||
providers: [WeatherService],
|
||||
exports: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
const CURRENT = {
|
||||
temperature_2m: 18.4,
|
||||
apparent_temperature: 16.9,
|
||||
relative_humidity_2m: 65.2,
|
||||
wind_speed_10m: 12.3,
|
||||
weather_code: 2,
|
||||
};
|
||||
|
||||
const EXPECTED_REPORT =
|
||||
'Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h';
|
||||
|
||||
describe('WeatherService formatReport', () => {
|
||||
it('formats a one-line report from the current-weather payload', () => {
|
||||
const svc = new WeatherService({} as MeshService);
|
||||
expect(svc.formatReport(CURRENT)).toBe(EXPECTED_REPORT);
|
||||
});
|
||||
|
||||
it('falls back to `code <n>` for unknown WMO codes', () => {
|
||||
const svc = new WeatherService({} as MeshService);
|
||||
expect(svc.formatReport({ ...CURRENT, weather_code: 99 })).toBe(
|
||||
EXPECTED_REPORT.replace('partly cloudy', 'code 99'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeatherService cron report', () => {
|
||||
it('fetches and broadcasts the formatted report on success', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const fetchMock = vi.fn(async (..._args: unknown[]) => ({
|
||||
ok: true,
|
||||
json: async () => ({ current: CURRENT }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const svc = new WeatherService({ sendText } as unknown as MeshService);
|
||||
await svc.report();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = String(fetchMock.mock.calls[0][0]);
|
||||
expect(url.startsWith('https://api.open-meteo.com/v1/forecast?')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(url).toContain('latitude=51.2465');
|
||||
expect(url).toContain('longitude=22.5684');
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(BROADCAST_ADDR, EXPECTED_REPORT);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send and does not throw when the fetch fails', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const svc = new WeatherService({ sendText } as unknown as MeshService);
|
||||
await expect(svc.report()).resolves.toBeUndefined();
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import { MeshService } from '../mesh/mesh.service.js';
|
||||
|
||||
/** Open-Meteo `current` block fields we use (free API, no key). */
|
||||
export interface CurrentWeather {
|
||||
temperature_2m: number;
|
||||
apparent_temperature: number;
|
||||
relative_humidity_2m: number;
|
||||
wind_speed_10m: number;
|
||||
weather_code: number;
|
||||
}
|
||||
|
||||
// WMO weather interpretation codes (the common ones); unknown → `code <n>`
|
||||
const WMO_CODES: Record<number, string> = {
|
||||
0: 'clear',
|
||||
1: 'mainly clear',
|
||||
2: 'partly cloudy',
|
||||
3: 'overcast',
|
||||
45: 'fog',
|
||||
48: 'freezing fog',
|
||||
51: 'light drizzle',
|
||||
53: 'drizzle',
|
||||
55: 'heavy drizzle',
|
||||
61: 'light rain',
|
||||
63: 'rain',
|
||||
65: 'heavy rain',
|
||||
71: 'light snow',
|
||||
73: 'snow',
|
||||
75: 'heavy snow',
|
||||
80: 'light rain showers',
|
||||
81: 'rain showers',
|
||||
82: 'violent rain showers',
|
||||
95: 'thunderstorm',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
private readonly logger = new Logger(WeatherService.name);
|
||||
// read at construction, like MeshService's env config
|
||||
private readonly lat = parseFloat(process.env.WEATHER_LAT ?? '51.2465');
|
||||
private readonly lon = parseFloat(process.env.WEATHER_LON ?? '22.5684');
|
||||
private readonly city = process.env.WEATHER_CITY ?? 'Lublin';
|
||||
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
/** Fetch current conditions from Open-Meteo; throws on HTTP/parse failure. */
|
||||
async fetchWeather(): Promise<CurrentWeather> {
|
||||
const url =
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${this.lat}` +
|
||||
`&longitude=${this.lon}` +
|
||||
`¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`open-meteo HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { current: CurrentWeather };
|
||||
return json.current;
|
||||
}
|
||||
|
||||
/** One short line, e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`. */
|
||||
formatReport(current: CurrentWeather): string {
|
||||
const desc =
|
||||
WMO_CODES[current.weather_code] ?? `code ${current.weather_code}`;
|
||||
return `${this.city} weather: ${Math.round(current.temperature_2m)}°C (feels ${Math.round(
|
||||
current.apparent_temperature,
|
||||
)}), ${desc}, humidity ${Math.round(current.relative_humidity_2m)}%, wind ${Math.round(
|
||||
current.wind_speed_10m,
|
||||
)} km/h`;
|
||||
}
|
||||
|
||||
/** Hourly cron: fetch → format → broadcast on the pinned send channel. Never throws — a failed report must not crash the app. */
|
||||
@Cron(process.env.WEATHER_CRON || '0 * * * *')
|
||||
async report(): Promise<void> {
|
||||
try {
|
||||
const current = await this.fetchWeather();
|
||||
await this.mesh.sendText(BROADCAST_ADDR, this.formatReport(current));
|
||||
} catch (err) {
|
||||
this.logger.warn(`[weather] report failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user