Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ce04d3f8 | ||
|
|
eda1cbd125 | ||
|
|
023cd3eef3 | ||
|
|
c5a2815c01 | ||
|
|
55e59d4963 | ||
|
|
9d4c9594af | ||
|
|
d375f2a943 | ||
|
|
931b42bb1e | ||
|
|
2b2e494023 | ||
|
|
4ae932befe |
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 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`.
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
|
||||
|
||||
- All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel.
|
||||
- Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted.
|
||||
- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) on that channel get an automatic reply routed through `MeshService.respond()` — DM back to the original sender, broadcast when the original was a broadcast — riding the original message's channel as a threaded quote (`replyId` = the trigger packet id). Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything.
|
||||
|
||||
## Notifications (mesh → agent)
|
||||
|
||||
- RX text on the notify channel (`MESH_NOTIFY_CHANNEL`, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
- RX text on a channel in the notify list (`MESH_NOTIFY_CHANNEL`, comma-separated indices, default 3) is pushed in-process to the live opencode session by `OpencodeService` (workspace/session resolved per request from the opencode API; disabled without `OPENCODE_USER`/`OPENCODE_PASS`). Messages from our own node are never notified (self-echo filter, v0.11.2).
|
||||
- Channel 0 is the shared default channel where other users talk: it carries real DMs when both sender and recipient are present, and the bot listens there too — but only directed messages trigger notifications; ch0 broadcasts are ignored. Synthetic injected messages (channel 0) are exempt from this rule.
|
||||
|
||||
@@ -32,7 +32,7 @@ All API endpoints live under the `/api` prefix:
|
||||
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<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 `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows
|
||||
- `POST /api/messages` — body `{ "text": "...", "from"?: <node number> }`, returns `{ id }`. Synthetic inbox injection for external triggers: records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later. NOT transmitted over RF; appears in `GET /api/messages` like any other rx message
|
||||
- `GET /api/forwarded` — the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel matched `MESH_NOTIFY_CHANNEL`, not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
|
||||
- `GET /api/forwarded` — the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel in the `MESH_NOTIFY_CHANNEL` list (ch0: directed DMs only), not a self-echo. The id is the packet id (real rx) or generated record id (injected); `from` omitted when there is no sender. In-memory only — reset on restart
|
||||
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
|
||||
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
|
||||
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
|
||||
@@ -105,7 +105,7 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
|
||||
|
||||
## Opencode notifier
|
||||
|
||||
In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id <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.
|
||||
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:
|
||||
|
||||
@@ -120,10 +120,14 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
|
||||
- `MESH_PORT` (default `4403`) — bridge port
|
||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
||||
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index whose rx texts trigger the opencode notifier (see "Opencode notifier"; synthetic injected messages, channel 0, trigger it too when set to match; messages from our own node are never notified)
|
||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified)
|
||||
- `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.
|
||||
|
||||
Generated
+123
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.11.3",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mesh-bot",
|
||||
"version": "0.11.3",
|
||||
"version": "0.19.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.15.0",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@nestjs/schedule": "^12.0.2",
|
||||
"@nestjs/swagger": "^12.0.2",
|
||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -1148,6 +1149,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.151.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
||||
@@ -2336,6 +2349,18 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -3279,6 +3304,45 @@
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
|
||||
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3830,6 +3894,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -4848,6 +4924,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -5503,6 +5594,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.5",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||
@@ -6147,6 +6253,21 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.16.0",
|
||||
"version": "0.20.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nestjs/schedule": "^12.0.2",
|
||||
"@nestjs/swagger": "^12.0.2",
|
||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
+11
-1
@@ -1,9 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BatteryAlertModule } from './battery/battery.module.js';
|
||||
import { CommandModule } from './commands/command.module.js';
|
||||
import { LlmModule } from './llm/llm.module.js';
|
||||
import { MeshModule } from './mesh/mesh.module.js';
|
||||
import { RssModule } from './rss/rss.module.js';
|
||||
import { WeatherModule } from './weather/weather.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule, WeatherModule, BatteryAlertModule],
|
||||
imports: [
|
||||
MeshModule,
|
||||
WeatherModule,
|
||||
BatteryAlertModule,
|
||||
CommandModule,
|
||||
LlmModule,
|
||||
RssModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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,321 @@
|
||||
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
|
||||
});
|
||||
|
||||
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('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('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,104 @@
|
||||
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 to send back, or void/undefined for no reply. */
|
||||
export type CommandHandler = (
|
||||
ctx: CommandContext,
|
||||
) => string | undefined | Promise<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,
|
||||
);
|
||||
// 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!');
|
||||
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
|
||||
if (id !== undefined && this.mesh) {
|
||||
// respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote)
|
||||
await this.mesh.respond(id, reply);
|
||||
} else {
|
||||
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
||||
await this.mesh?.sendText(
|
||||
from ?? BROADCAST_ADDR,
|
||||
reply,
|
||||
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 — generous default
|
||||
private readonly timeoutMs = parseInt(
|
||||
process.env.LLM_TIMEOUT_MS ?? '60000',
|
||||
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 },
|
||||
]);
|
||||
}
|
||||
}
|
||||
+17
-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(
|
||||
@@ -176,6 +176,21 @@ export function textPacketFrame(
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -245,7 +245,7 @@ export class MeshController {
|
||||
@ApiOperation({
|
||||
summary: 'Forwarded message history (last 100)',
|
||||
description:
|
||||
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel matched MESH_NOTIFY_CHANNEL, not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
|
||||
'The last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[] — channel in the MESH_NOTIFY_CHANNEL comma-separated list (ch0: directed DMs only), not a self-echo, notifier wired. The id is the packet id (real rx) or generated record id (injected); `from` is omitted when there is no sender. In-memory only — reset on restart.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
||||
+107
-15
@@ -11,6 +11,7 @@ import {
|
||||
metadataFrame,
|
||||
moduleConfigSectionFrame,
|
||||
nodeInfoFrame,
|
||||
outboundPackets,
|
||||
startBridge,
|
||||
telemetryPacketFrame,
|
||||
textPacketFrame,
|
||||
@@ -667,21 +668,6 @@ describe('MeshService message history', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// outbound text packets written to the bridge, in order (want_config frames skipped)
|
||||
function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
|
||||
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
|
||||
let off = 0;
|
||||
for (const c of out) {
|
||||
merged.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
return extractFrames(merged).packets.flatMap((p) => {
|
||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('MeshService respond', () => {
|
||||
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
|
||||
const bridge = await startBridge();
|
||||
@@ -1046,6 +1032,112 @@ describe('MeshService opencode notifier', () => {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('notifies for rx texts on any channel in a comma-separated MESH_NOTIFY_CHANNEL list, and not for others', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_CHANNEL = '2,5';
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
// channel 5 is in the list — notified
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'on-list', 5));
|
||||
|
||||
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||
expect(notifyMock).toHaveBeenCalledWith(1, 'on-list', KAMIL_NUM);
|
||||
|
||||
// channel 3 is not in the list — captured in history but no notification
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'off-list', 3));
|
||||
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores ch0 broadcasts even when 0 is in the list, but notifies for directed DMs', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_CHANNEL = '3,0';
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
// ch0 broadcast: shared default channel noise — no notification
|
||||
bridge.sockets[0].write(
|
||||
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, 'hello all', 0),
|
||||
);
|
||||
|
||||
await waitFor(() => svc.messages().length === 1);
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
|
||||
// ch0 directed DM: both sender and recipient present — notified
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'dm', 0));
|
||||
|
||||
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||
expect(notifyMock).toHaveBeenCalledWith(1, 'dm', KAMIL_NUM);
|
||||
} finally {
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('notifies for a ch0 injection when 0 is in the list (multi-list)', () => {
|
||||
process.env.MESH_NOTIFY_CHANNEL = '3,0';
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
const r = svc.injectMessage('hi', 42);
|
||||
|
||||
// injected records are exempt from the ch0 DM-only rule
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith(r.id, 'hi', 42);
|
||||
} finally {
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores empty and invalid entries in MESH_NOTIFY_CHANNEL', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
process.env.MESH_NOTIFY_CHANNEL = 'abc, ,5,-2,x7';
|
||||
const svc = makeSvc();
|
||||
try {
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
// only the valid entry (5) survives parsing — notified
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 5));
|
||||
|
||||
await waitFor(() => notifyMock.mock.calls.length === 1);
|
||||
expect(notifyMock).toHaveBeenCalledWith(1, 'ping', KAMIL_NUM);
|
||||
|
||||
// channel 7 only appears inside the invalid entry "x7" — must not match
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'nope', 7));
|
||||
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService forwarded ring', () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ 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,
|
||||
@@ -161,6 +163,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). */
|
||||
@@ -190,11 +201,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on the notify channel;
|
||||
// outbound notification: fire-and-forget push to the opencode notifier when an rx text lands on one of the notify channels;
|
||||
// disabled state (missing credentials) lives in OpencodeService itself
|
||||
private readonly notifyChannel = parseInt(
|
||||
private readonly notifyChannels = parseNotifyChannels(
|
||||
process.env.MESH_NOTIFY_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// how often the poller re-upserts the history buffer into SQLite (ms)
|
||||
private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10);
|
||||
@@ -203,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
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, opencode?: OpencodeService) {
|
||||
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;
|
||||
@@ -506,19 +528,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
at: Date.now(),
|
||||
};
|
||||
this.pushHistory(rec);
|
||||
// injected records ride channel 0 — the notifier fires only when MESH_NOTIFY_CHANNEL is set to match
|
||||
this.notifyRx(0, text, from, rec.id);
|
||||
// injected records ride channel 0 — the notifier fires only when 0 is in MESH_NOTIFY_CHANNEL (injections are exempt from the ch0 DM-only rule)
|
||||
this.notifyRx(0, text, from, rec.id, undefined, true);
|
||||
return { id: rec.id };
|
||||
}
|
||||
|
||||
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless channels match and a notifier is wired). Both call sites pass the packet id. */
|
||||
/** Fire-and-forget opencode notifier push for an rx text on `channel` (no-op unless the channel is in the notify list and a notifier is wired). Both call sites pass the packet id; `injected` marks synthetic records that ride ch0 by design. */
|
||||
private notifyRx(
|
||||
channel: number,
|
||||
text: string,
|
||||
from?: number,
|
||||
id?: number,
|
||||
to?: number,
|
||||
injected = false,
|
||||
): void {
|
||||
if (!this.opencode || channel !== this.notifyChannel) return;
|
||||
if (!this.opencode || !this.notifyChannels.includes(channel)) return;
|
||||
// ch0 is the shared default channel where other users talk — only directed DMs (a real destination) are notified there; broadcasts and undirected traffic are noise. Injected records ride ch0 by design, so they're exempt.
|
||||
if (
|
||||
channel === 0 &&
|
||||
!injected &&
|
||||
(to === undefined || to === BROADCAST_ADDR)
|
||||
)
|
||||
return;
|
||||
// self-echo filter: our own node must never trigger a notification of itself
|
||||
if (from !== undefined && from === this.myNodeNum) {
|
||||
this.logger.debug(`notify skipped: rx from our own node ${from}`);
|
||||
@@ -718,7 +749,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
channel: pkt.channel ?? 0,
|
||||
at: Date.now(),
|
||||
});
|
||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id);
|
||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
|
||||
// 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;
|
||||
|
||||
@@ -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,206 @@
|
||||
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 { 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 the latest titles via chat with a bounded maxTokens', async () => {
|
||||
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
||||
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||
llm.chat.mockResolvedValueOnce('Alpha and beta happened.');
|
||||
|
||||
const reply = await svc.handle(ctx('#rss get myfeed'));
|
||||
|
||||
expect(reply).toBe('Alpha and beta happened.');
|
||||
const [messages, opts] = llm.chat.mock.calls[0];
|
||||
expect(opts).toEqual({ maxTokens: 200 });
|
||||
expect(messages[0].content).toContain('Alpha headline');
|
||||
expect(messages[0].content).toContain('Beta headline');
|
||||
});
|
||||
|
||||
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('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).toBe('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('all three.');
|
||||
await svc.handle(ctx('#rss get #tech'));
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
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;
|
||||
const SUMMARY_MAX_TOKENS = 200;
|
||||
|
||||
/**
|
||||
* The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with
|
||||
* LLM-generated tags and on-demand headline summaries. All replies are short strings —
|
||||
* CommandService routes them through MeshService.respond() like any other command reply.
|
||||
*/
|
||||
@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). */
|
||||
async handle(ctx: CommandContext): Promise<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>`. */
|
||||
private async get(target: string): Promise<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 {
|
||||
return await this.llm.chat(
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize these RSS headlines into a few short one-line highlights. Be concise:\n${titles.join('\n')}`,
|
||||
},
|
||||
],
|
||||
{ maxTokens: SUMMARY_MAX_TOKENS },
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(',');
|
||||
}
|
||||
Reference in New Issue
Block a user