Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ce49c741 | ||
|
|
cdd52a3ddb | ||
|
|
172e7bed9c | ||
|
|
e2d712a26c | ||
|
|
a8ce04d3f8 | ||
|
|
eda1cbd125 | ||
|
|
023cd3eef3 | ||
|
|
c5a2815c01 | ||
|
|
55e59d4963 | ||
|
|
9d4c9594af | ||
|
|
d375f2a943 | ||
|
|
931b42bb1e |
@@ -27,3 +27,5 @@
|
|||||||
|
|
||||||
- MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
|
- MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
|
||||||
- Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`.
|
- Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`.
|
||||||
|
- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns a reply string — or an array of lines, sent as one message per line through the same routing — or void = no reply). First command: `#ping` → replies `#pong!`. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add <url> <name>` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove <name>`; `#rss tags` (all distinct tags); `#rss sources`; `#rss get <name|#tag>` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `<short header>: <one or two short sentences>` with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop `fitOneLine()` hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 2500 summary headroom).
|
||||||
|
- LLM client (`src/llm`): `LlmService` wraps the local OpenAI-compatible server (default `http://192.168.1.70:1111/v1`, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods: `listModels()` → `data[].id`; `chat(messages, { maxTokens?, temperature? })` → `choices[0].message.content` (non-2xx throws with status + body snippet); `summarize(text)` = chat() with system prompt "Summarize the following text concisely." Env: `LLM_API_BASE`, `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default 60000, via `AbortSignal.timeout`).
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.0 — 2026-09-25 19:59
|
||||||
|
|
||||||
|
- `#rss get <name|#tag>` now replies with **3–5 SEPARATE mesh messages** instead of one small summary (owner request: the single-message summary was too small). Each item is its own packet, so no fragmentation — and each rides the trigger's channel threaded as a quote of the command packet.
|
||||||
|
- New summarizer prompt asks for exactly 3-5 lines, one item per line, format `<short header>: <one or two short sentences>` with a ≤20-char header (e.g. `Culture: Museum opens new wing`), no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom — see v0.20.1).
|
||||||
|
- The LLM reply is parsed into items: split on newlines, trim, drop empty lines and lines without a `:` header separator, cap at 5. Fewer than 3 valid lines → send what we got (no failure, no retry — the model is non-deterministic); zero valid lines → no message sent.
|
||||||
|
- Per-item backstop replaces the old whole-reply cut: any single line >185 chars is hard-cut at a word boundary near 180 (`fitOneLine`), so every item fits one packet even if the model ignores the prompt. `fitOnePacket()` (whole-reply ≤190) is gone — it no longer applies to joined text.
|
||||||
|
- CommandService now sends array replies line by line: each element goes through the same routing as a single reply (`respond(id, line)` threaded to the trigger packet id; legacy sendText fallback per line when there is no packet id). A failing send stops the remaining lines (warn log), like any failed command.
|
||||||
|
- LLM failure path unchanged: single `summary failed` reply via respond(). `#rss get #tag` gets the same multi-message treatment automatically (shared summary path — covered by tests). All other subcommands unchanged.
|
||||||
|
- Tests: 158 → 165 — prompt shape + maxTokens asserted on the new call, 5-item cap, invalid-line filtering, fewer-than-3 fallback, per-line over-length cut through `handle()`, direct `parseSummaryItems`/`fitOneLine` specs, and CommandService array-reply routing (threaded respond ×N to one packet id; legacy sendText fallback).
|
||||||
|
|
||||||
|
## v0.20.1 — 2026-09-25 19:20
|
||||||
|
|
||||||
|
- `#rss get` summaries now fit ONE mesh packet (≤190 chars). Mesh fragments longer replies into fire-and-forget packets that recipients frequently never receive, and the old prompt ("a few short one-line highlights") produced ~326-334 char replies.
|
||||||
|
- The summary prompt now asks for "at most 3 short bullet lines, each under 45 characters … bullets only - no intro, no other text" (verified against the live LLM: exactly 3 compliant bullets, ~110 chars). A `fitOnePacket()` backstop additionally cuts any over-length reply at complete-bullet boundaries so delivery is one packet even if the model ignores the prompt.
|
||||||
|
- The summary `chat()` call now uses an explicit `maxTokens = 2500` (was 200). Empirically verified on this server: qwen3.8-27b emits `reasoning_content` and those tokens COUNT against max_tokens (cap 150 → completion_tokens 150, of which 105 were reasoning, finish_reason 'length'; a cap of 500 was fully consumed by reasoning with zero visible content). Thinking length for this task is unbounded and uncontrollable server-side (`/no_think`, `chat_template_kwargs.enable_thinking` and `thinking_budget` are all ignored) — observed 32..1776 tokens across runs. 2500 covers every observed run plus ~80 content tokens, and keeps the worst case (~42 s at ~60 tok/s) under the 60 s LLM timeout. The old cap of 200 guaranteed empty replies whenever reasoning exceeded it.
|
||||||
|
- Tests: 154 → 158 — prompt shape + maxTokens asserted on the summary call, over-length reply cut through `handle()`, and direct `fitOnePacket` specs (pass-through at/under 190, bullet-boundary cut, hard-cut fallback for non-bulleted paragraphs).
|
||||||
|
|
||||||
|
## v0.20.0 — 2026-09-25 18:40
|
||||||
|
|
||||||
|
- New `#rss` command family on the command channel (ch2): `#rss help`, `#rss add <url> <name>` (validates URL + fetches with a 15 s timeout, requires ≥1 item with a non-empty title, rejects duplicate names case-insensitively; tags are generated by the LLM from up to 8 latest titles — on LLM failure the source is still added with empty tags and the reply says so), `#rss remove <name>`, `#rss tags` (all distinct tags), `#rss sources`, `#rss get <name>` and `#rss get #<tag>` (LLM headline summary, maxTokens 200; tag form collects items across ALL sources carrying the tag — one dead feed does not sink the summary). Bare `#rss` / unknown subcommand → help. All replies ride the existing respond() path like `#ping`.
|
||||||
|
- Storage: new `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file (default ./data/mesh-bot.db) via a second node:sqlite connection opened by `RssModule`'s factory — no new DB file. Names are matched case-insensitively everywhere (`COLLATE NOCASE`).
|
||||||
|
- Feed parsing: new dependency `fast-xml-parser` (the only one added for this feature) — RSS 2.0 (`<item><title>/<link>`) and Atom (`<entry><title>`, link as `href` attribute); CDATA titles supported; malformed input or no usable items → "not a valid feed".
|
||||||
|
- LLM: `RssService` injects `LlmService` (plain DI, one-way — `RssModule` imports `LlmModule`) and uses `chat()` directly with explicit maxTokens (50 for tags, 200 for summaries) to keep mesh replies short. Tag output is sanitized (lowercase, no #, whitespace → '-', deduped).
|
||||||
|
- Dispatch: `CommandService` gains prefix registration (`registerPrefix(name, fn)`) alongside exact matches — one handler owns the whole `#rss` family while `#ping` stays an untouched exact match; exact names win over prefixes, and a name merely starting with the prefix (e.g. `#rssfoo`) does not dispatch. Wired in `CommandModule`'s factory (`inject: [MeshService, RssService]`) — still no constructor-level cycle, no forwardRef.
|
||||||
|
- Tests: 128 → 153 specs — store CRUD against a real temp SQLite file (incl. second-connection persistence), feed parser fixtures (RSS 2.0 / Atom / malformed / empty titles), handler behavior with mocked LLM + fetch (help, add success/LLM-fail/invalid-url/duplicate/non-2xx/unparseable, remove found/missing, tags/sources empty+populated, get by name/by tag across sources/dead-feed resilience/unknown name+tag), and `#rss` prefix dispatch through respond().
|
||||||
|
|
||||||
|
## v0.19.0 — 2026-09-25 18:03
|
||||||
|
|
||||||
|
- New LLM client module (`src/llm`): `LlmService` speaks the OpenAI-compatible chat-completions API of the local server at `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`, no auth) using native fetch only — no new dependencies. Plain feature module (`LlmModule`), wired into `app.module.ts`; no Mesh/Schedule dependency.
|
||||||
|
- Methods: `listModels()` (GET `/models` → `data[].id` strings), `chat(messages, opts?)` (POST `/chat/completions` with `{ model, messages, ...opts }` → `choices[0].message.content`; non-2xx throws an Error carrying the HTTP status and a short body snippet; malformed responses without content throw too), and `summarize(text)` — thin wrapper over `chat()` with system prompt "Summarize the following text concisely." (the stated primary use).
|
||||||
|
- Env: `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`), `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default `60000` — the local 27B model can be slow; applied via `AbortSignal.timeout`).
|
||||||
|
- Tests: +4 specs — listModels parses ids, chat passes model/messages/opts through and returns content, chat throws on non-2xx with status in the message, summarize sends system prompt + user text.
|
||||||
|
|
||||||
|
## v0.18.1 — 2026-09-25 17:30
|
||||||
|
|
||||||
|
- Command replies now ride `MeshService.respond(id, reply)` instead of direct `sendText` routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the **original message's channel** with `replyId = id`, so the recipient app renders it as a threaded quote of the triggering packet. `COMMAND_CHANNEL` is now only the trigger/listen gate — the reply channel comes from the record.
|
||||||
|
- Defensive fallback: when the trigger has no packet id (never happens in production — `MeshService` always passes `pkt.id`), the legacy sendText routing on `COMMAND_CHANNEL` (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes.
|
||||||
|
- Tests: 121 → 124 specs — unit cases now mock `mesh.respond` (id present → `respond(id, reply)` with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: `#ping` broadcast on ch2 → `#pong!` broadcast on ch2 with `replyId` = ping packet id, and `#ping` DM → `#pong!` DM to the sender on ch2 with `replyId` set. All gate tests (case/whitespace/channel/env override) kept.
|
||||||
|
- The wire-decode test helper `outboundPackets()` moved from `mesh.service.spec.ts` into `fake-bridge.ts` so both specs share it.
|
||||||
|
- Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (`COMMAND_CHANNEL` still exists, now meaning trigger channel only).
|
||||||
|
|
||||||
|
## v0.18.0 — 2026-09-25 17:08
|
||||||
|
|
||||||
|
- New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`.
|
||||||
|
- Reply routing v1: directed DM back to the sender when present, else broadcast — always on `COMMAND_CHANNEL`, via the existing `MeshService.sendText(to, text, undefined, channel)` path. No threading/`replyId` in v1. A failing handler or send only logs a warn; it never crashes the app.
|
||||||
|
- Wiring: `MeshService` gains an optional third constructor param (`commands?`) plus a public `attachCommands()` — the rx handler calls `this.commands?.onRx(channel, text, from, id)` right after `notifyRx()`, fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so `CommandModule` provides `CommandService` via a factory that injects `MeshService` and attaches itself as a side effect — no `forwardRef`, no module cycle.
|
||||||
|
- Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, `COMMAND_CHANNEL` override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (`#ping` on ch2 answered with a directed `#pong!` tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances.
|
||||||
|
- Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains `COMMAND_CHANNEL`.
|
||||||
|
|
||||||
## v0.17.0 — 2026-09-25 16:18
|
## 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]`).
|
- 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]`).
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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.
|
- 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.
|
- 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)
|
## Notifications (mesh → agent)
|
||||||
|
|
||||||
|
|||||||
@@ -121,9 +121,13 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
|
|||||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
- `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_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
||||||
- `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)
|
- `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_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
|
- `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
|
- `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
|
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.
|
connected to the radio, this bot will not get a connection.
|
||||||
|
|||||||
Generated
+123
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.11.3",
|
"version": "0.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.11.3",
|
"version": "0.19.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/swagger": "^12.0.2",
|
"@nestjs/swagger": "^12.0.2",
|
||||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||||
|
"fast-xml-parser": "^5.11.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -1148,6 +1149,18 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.151.0",
|
"version": "0.151.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
||||||
@@ -2336,6 +2349,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anynum": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/append-field": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -3279,6 +3304,45 @@
|
|||||||
"fast-string-width": "^3.0.2"
|
"fast-string-width": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
|
||||||
|
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^3.0.0",
|
||||||
|
"fast-xml-builder": "^1.2.0",
|
||||||
|
"is-unsafe": "^2.0.0",
|
||||||
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"strnum": "^2.4.2",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3830,6 +3894,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-unsafe": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4848,6 +4924,21 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -5503,6 +5594,21 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anynum": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strtok3": {
|
"node_modules/strtok3": {
|
||||||
"version": "10.3.5",
|
"version": "10.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||||
@@ -6147,6 +6253,21 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yoctocolors": {
|
"node_modules/yoctocolors": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.17.0",
|
"version": "0.21.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/swagger": "^12.0.2",
|
"@nestjs/swagger": "^12.0.2",
|
||||||
"@scalar/nestjs-api-reference": "^1.2.21",
|
"@scalar/nestjs-api-reference": "^1.2.21",
|
||||||
|
"fast-xml-parser": "^5.11.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-1
@@ -1,9 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BatteryAlertModule } from './battery/battery.module.js';
|
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 { MeshModule } from './mesh/mesh.module.js';
|
||||||
|
import { RssModule } from './rss/rss.module.js';
|
||||||
import { WeatherModule } from './weather/weather.module.js';
|
import { WeatherModule } from './weather/weather.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MeshModule, WeatherModule, BatteryAlertModule],
|
imports: [
|
||||||
|
MeshModule,
|
||||||
|
WeatherModule,
|
||||||
|
BatteryAlertModule,
|
||||||
|
CommandModule,
|
||||||
|
LlmModule,
|
||||||
|
RssModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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,345 @@
|
|||||||
|
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('sends each line of an array reply as its own threaded respond() to the same packet id', async () => {
|
||||||
|
const [svc, mesh] = makeService();
|
||||||
|
svc.register('multi', () => ['one: first item', 'two: second item']);
|
||||||
|
await svc.onRx(2, '#multi', 42, 9);
|
||||||
|
expect(mesh.respond).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mesh.respond.mock.calls[0]).toEqual([9, 'one: first item']);
|
||||||
|
expect(mesh.respond.mock.calls[1]).toEqual([9, 'two: second item']);
|
||||||
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
|
||||||
|
const [svc, mesh] = makeService();
|
||||||
|
svc.register('multi', () => ['one: first item', 'two: second item']);
|
||||||
|
await svc.onRx(2, '#multi', 42); // no id → legacy path on COMMAND_CHANNEL, DM back to the sender
|
||||||
|
expect(mesh.sendText).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mesh.sendText.mock.calls[0]).toEqual([
|
||||||
|
42,
|
||||||
|
'one: first item',
|
||||||
|
undefined,
|
||||||
|
2,
|
||||||
|
]);
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
||||||
|
const [svc, mesh] = makeService();
|
||||||
|
svc.register('boom', () => {
|
||||||
|
throw new Error('kaboom');
|
||||||
|
});
|
||||||
|
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a failing respond (unknown id / bridge down) without throwing', async () => {
|
||||||
|
const [svc] = makeService(
|
||||||
|
undefined,
|
||||||
|
vi.fn().mockRejectedValue(new Error('no message with id 7')),
|
||||||
|
);
|
||||||
|
await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a failing send (mesh down) without throwing', async () => {
|
||||||
|
const [svc] = makeService(
|
||||||
|
vi.fn().mockRejectedValue(new Error('no bridge')),
|
||||||
|
);
|
||||||
|
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#rss prefix dispatch', () => {
|
||||||
|
let dir: string | undefined;
|
||||||
|
|
||||||
|
const makeRss = (): RssService => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-cmd-rss-'));
|
||||||
|
return new RssService(
|
||||||
|
{ chat: vi.fn() } as unknown as LlmService,
|
||||||
|
new DatabaseSync(join(dir, 'test.db')),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||||
|
dir = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes #rss subcommands through respond() like any command reply', async () => {
|
||||||
|
const mesh = {
|
||||||
|
sendText: vi.fn(),
|
||||||
|
respond: vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
} as unknown as MeshService;
|
||||||
|
const svc = new CommandService(mesh, makeRss());
|
||||||
|
|
||||||
|
await svc.onRx(2, '#rss sources', 42, 9);
|
||||||
|
|
||||||
|
expect(mesh.respond).toHaveBeenCalledWith(9, 'no sources yet');
|
||||||
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes bare #rss to the help text via respond()', async () => {
|
||||||
|
const mesh = {
|
||||||
|
sendText: vi.fn(),
|
||||||
|
respond: vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
} as unknown as MeshService;
|
||||||
|
const svc = new CommandService(mesh, makeRss());
|
||||||
|
|
||||||
|
await svc.onRx(2, '#rss', 42, 9);
|
||||||
|
|
||||||
|
expect(mesh.respond).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(mesh.respond.mock.calls[0][1])).toContain(
|
||||||
|
'#rss add <url> <name>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves exact commands untouched and does not partial-match prefix names', async () => {
|
||||||
|
const mesh = {
|
||||||
|
sendText: vi.fn(),
|
||||||
|
respond: vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
} as unknown as MeshService;
|
||||||
|
const svc = new CommandService(mesh, makeRss());
|
||||||
|
|
||||||
|
await svc.onRx(2, '#ping', 42, 3);
|
||||||
|
expect(mesh.respond).toHaveBeenCalledWith(3, '#pong!');
|
||||||
|
await svc.onRx(2, '#rssfoo', 42, 4); // starts with 'rss' but is not the family — no partial prefix match
|
||||||
|
expect(mesh.respond).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for #rss when no RssService was wired (legacy construction)', async () => {
|
||||||
|
const [svc, mesh] = makeService(); // rss omitted
|
||||||
|
await svc.onRx(2, '#rss sources', 42, 9);
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService wiring (event-driven trigger)', () => {
|
||||||
|
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new RealMeshService();
|
||||||
|
try {
|
||||||
|
// wire the responder exactly like command.module.ts does in production
|
||||||
|
svc.attachCommands(new CommandService(svc));
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
|
||||||
|
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
||||||
|
|
||||||
|
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
||||||
|
await waitFor(() => svc.messages().length === 2);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: KAMIL_NUM,
|
||||||
|
text: '#pong!',
|
||||||
|
channel: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
|
||||||
|
await waitFor(() => outboundPackets(out).length >= 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(KAMIL_NUM);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new RealMeshService();
|
||||||
|
try {
|
||||||
|
svc.attachCommands(new CommandService(svc));
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
|
||||||
|
// rx broadcast from KAMIL on channel 2 (packet id 1)
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages().length === 2);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
||||||
|
text: '#pong!',
|
||||||
|
channel: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => outboundPackets(out).length >= 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(BROADCAST_ADDR);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not answer #ping arriving on a non-command channel', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new RealMeshService();
|
||||||
|
try {
|
||||||
|
svc.attachCommands(new CommandService(svc));
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'rx',
|
||||||
|
text: '#ping',
|
||||||
|
channel: 3,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||||
|
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
|
||||||
|
import type { MeshService } from '../mesh/mesh.service.js';
|
||||||
|
import type { RssService } from '../rss/rss.service.js';
|
||||||
|
|
||||||
|
/** Context handed to a command handler: the trimmed text plus sender/packet id when present. */
|
||||||
|
export interface CommandContext {
|
||||||
|
/** the trimmed rx text, e.g. `#ping` */
|
||||||
|
text: string;
|
||||||
|
/** sender node num — omitted for broadcasts/unknown senders */
|
||||||
|
from?: number;
|
||||||
|
/** packet id of the triggering message */
|
||||||
|
id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A command handler: returns the reply text(s) to send back — an array sends one message per line — or void/undefined for no reply. */
|
||||||
|
export type CommandHandler = (
|
||||||
|
ctx: CommandContext,
|
||||||
|
) => string | string[] | undefined | Promise<string | string[] | undefined>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandService {
|
||||||
|
private readonly logger = new Logger(CommandService.name);
|
||||||
|
// read at construction, like other services' env config
|
||||||
|
private readonly commandChannel = parseInt(
|
||||||
|
process.env.COMMAND_CHANNEL ?? '2',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
// 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
|
||||||
|
// an array reply sends one message per line, all threaded to the same trigger packet id
|
||||||
|
for (const line of Array.isArray(reply) ? reply : [reply]) {
|
||||||
|
if (id !== undefined && this.mesh) {
|
||||||
|
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
|
||||||
|
await this.mesh.respond(id, line);
|
||||||
|
} else {
|
||||||
|
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
||||||
|
await this.mesh?.sendText(
|
||||||
|
from ?? BROADCAST_ADDR,
|
||||||
|
line,
|
||||||
|
undefined,
|
||||||
|
this.commandChannel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MeshService } from '../mesh/mesh.service.js';
|
||||||
|
import { CommandModule } from './command.module.js';
|
||||||
|
import { CommandService } from './command.service.js';
|
||||||
|
|
||||||
|
describe('DI wiring (module graph)', () => {
|
||||||
|
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||||
|
it('resolves CommandService with MeshService provided by MeshModule', async () => {
|
||||||
|
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [CommandModule],
|
||||||
|
}).compile();
|
||||||
|
expect(moduleRef.get(CommandService)).toBeInstanceOf(CommandService);
|
||||||
|
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// the MeshService <-> CommandService cycle must resolve to live references on both sides, not undefined
|
||||||
|
it('wires both sides of the circular dependency with real instances', async () => {
|
||||||
|
process.env.MESH_DB_PATH = ':memory:';
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [CommandModule],
|
||||||
|
}).compile();
|
||||||
|
const mesh = moduleRef.get(MeshService);
|
||||||
|
const commands = moduleRef.get(CommandService);
|
||||||
|
expect((commands as unknown as { mesh?: MeshService }).mesh).toBe(mesh);
|
||||||
|
expect((mesh as unknown as { commands?: CommandService }).commands).toBe(
|
||||||
|
commands,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LlmService } from './llm.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [LlmService],
|
||||||
|
exports: [LlmService],
|
||||||
|
})
|
||||||
|
export class LlmModule {}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ChatMessage } from './llm.service.js';
|
||||||
|
import { LlmService } from './llm.service.js';
|
||||||
|
|
||||||
|
const BASE = 'http://192.168.1.70:1111/v1';
|
||||||
|
|
||||||
|
function json(body: unknown): Response {
|
||||||
|
return { ok: true, status: 200, json: async () => body } as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh service with a clean env (explicit base/model/timeout). */
|
||||||
|
function makeService(): LlmService {
|
||||||
|
process.env.LLM_API_BASE = BASE;
|
||||||
|
process.env.LLM_MODEL = 'qwen3.8-27b';
|
||||||
|
process.env.LLM_TIMEOUT_MS = '60000';
|
||||||
|
return new LlmService();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LlmService', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
for (const k of ['LLM_API_BASE', 'LLM_MODEL', 'LLM_TIMEOUT_MS'])
|
||||||
|
delete process.env[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listModels returns the data[].id strings from GET /models', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
data: [
|
||||||
|
{ id: 'qwen3.8-27b' },
|
||||||
|
{ id: 'text-embedding-nomic-embed-text-v1.5' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.listModels()).resolves.toEqual([
|
||||||
|
'qwen3.8-27b',
|
||||||
|
'text-embedding-nomic-embed-text-v1.5',
|
||||||
|
]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/models`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat posts model+messages and returns choices[0].message.content', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ choices: [{ message: { content: 'hello back' } }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: 'system', content: 'be brief' },
|
||||||
|
{ role: 'user', content: 'hi' },
|
||||||
|
];
|
||||||
|
await expect(
|
||||||
|
svc.chat(messages, { maxTokens: 50, temperature: 0.2 }),
|
||||||
|
).resolves.toBe('hello back');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe(`${BASE}/chat/completions`);
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
model: 'qwen3.8-27b',
|
||||||
|
messages,
|
||||||
|
maxTokens: 50,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
text: async () => 'upstream down',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(svc.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
|
||||||
|
/HTTP 503.*upstream down/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarize calls chat with the system prompt plus the user text', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ choices: [{ message: { content: 'the summary' } }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.summarize('a long text')).resolves.toBe('the summary');
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(JSON.parse(init.body).messages).toEqual([
|
||||||
|
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||||
|
{ role: 'user', content: 'a long text' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** A single chat message in OpenAI chat-completions shape. */
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LlmService {
|
||||||
|
// read at construction, like other services' env config
|
||||||
|
private readonly apiBase =
|
||||||
|
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
||||||
|
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
||||||
|
// local 27B model can be slow — 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 P from '@meshtastic/protobufs';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
import type { AddressInfo } 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. */
|
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
|
||||||
export function startBridge(
|
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 const KAMIL_NUM = 123456789;
|
||||||
|
|
||||||
export function kamilNodeInfo(): NodeInfoFields {
|
export function kamilNodeInfo(): NodeInfoFields {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
metadataFrame,
|
metadataFrame,
|
||||||
moduleConfigSectionFrame,
|
moduleConfigSectionFrame,
|
||||||
nodeInfoFrame,
|
nodeInfoFrame,
|
||||||
|
outboundPackets,
|
||||||
startBridge,
|
startBridge,
|
||||||
telemetryPacketFrame,
|
telemetryPacketFrame,
|
||||||
textPacketFrame,
|
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', () => {
|
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 () => {
|
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();
|
const bridge = await startBridge();
|
||||||
|
|||||||
@@ -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)
|
// 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 { DatabaseSync } from 'node:sqlite';
|
||||||
import type * as Proto from '@meshtastic/protobufs';
|
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 { OpencodeService } from '../opencode/opencode.service.js';
|
||||||
import {
|
import {
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
@@ -211,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private db: DatabaseSync | null = null;
|
private db: DatabaseSync | null = null;
|
||||||
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
||||||
private readonly opencode: OpencodeService | null;
|
private 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;
|
private pollTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(db?: DatabaseSync, opencode?: OpencodeService) {
|
constructor(
|
||||||
|
db?: DatabaseSync,
|
||||||
|
opencode?: OpencodeService,
|
||||||
|
commands?: CommandService,
|
||||||
|
) {
|
||||||
this.db = db ?? null;
|
this.db = db ?? null;
|
||||||
this.opencode = opencode ?? 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;
|
private socket: net.Socket | null = null;
|
||||||
@@ -736,6 +750,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
});
|
});
|
||||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
|
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) {
|
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||||
// live position update: the Position is serialized in Data.payload
|
// live position update: the Position is serialized in Data.payload
|
||||||
let pos: Proto.Mesh.Position | undefined;
|
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,317 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { LlmService } from '../llm/llm.service.js';
|
||||||
|
import { RssStore } from './rss.store.js';
|
||||||
|
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
|
||||||
|
|
||||||
|
const FEED_A = `<rss version="2.0"><channel>
|
||||||
|
<item><title>Alpha headline</title></item>
|
||||||
|
<item><title>Beta headline</title></item>
|
||||||
|
</channel></rss>`;
|
||||||
|
const FEED_B = `<rss version="2.0"><channel>
|
||||||
|
<item><title>Gamma headline</title></item>
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
function feedResponse(xml: string): Response {
|
||||||
|
return { ok: true, status: 200, text: async () => xml } as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RssService.handle', () => {
|
||||||
|
let dir: string;
|
||||||
|
let dbFile: string;
|
||||||
|
let llm: { chat: ReturnType<typeof vi.fn> };
|
||||||
|
let svc: RssService;
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-svc-'));
|
||||||
|
dbFile = join(dir, 'test.db');
|
||||||
|
llm = { chat: vi.fn() };
|
||||||
|
svc = new RssService(
|
||||||
|
llm as unknown as LlmService,
|
||||||
|
new DatabaseSync(dbFile),
|
||||||
|
);
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = (text: string) => ({ text });
|
||||||
|
// second connection — the same shape production uses for a separate table in one file
|
||||||
|
const stored = () => new RssStore(new DatabaseSync(dbFile));
|
||||||
|
|
||||||
|
it('replies with usage on bare #rss and on unknown subcommands', async () => {
|
||||||
|
const help = await svc.handle(ctx('#rss'));
|
||||||
|
expect(help).toContain('#rss add <url> <name>');
|
||||||
|
expect(help).toContain('#rss get <name|#tag>');
|
||||||
|
expect(await svc.handle(ctx('#rss frobnicate'))).toBe(help);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add: validates the feed, stores source with LLM tags, replies added (tags: ...)', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
llm.chat.mockResolvedValueOnce('#AI, News');
|
||||||
|
|
||||||
|
const reply = await svc.handle(
|
||||||
|
ctx('#rss add https://a.example/rss my feed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply).toBe('added my feed (tags: ai, news)');
|
||||||
|
expect(stored().get('my feed')).toMatchObject({
|
||||||
|
url: 'https://a.example/rss',
|
||||||
|
tags: ['ai', 'news'],
|
||||||
|
});
|
||||||
|
const [messages, opts] = llm.chat.mock.calls[0];
|
||||||
|
expect(opts).toEqual({ maxTokens: 50 });
|
||||||
|
expect(messages[0].content).toContain('Alpha headline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add: LLM failure still stores the source with empty tags and says so', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
llm.chat.mockRejectedValueOnce(new Error('llm down'));
|
||||||
|
|
||||||
|
const reply = await svc.handle(
|
||||||
|
ctx('#rss add https://a.example/rss myfeed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply).toBe('added myfeed (tag generation failed - no tags)');
|
||||||
|
expect(stored().get('myfeed')?.tags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add: rejects invalid urls without fetching or storing', async () => {
|
||||||
|
expect(await svc.handle(ctx('#rss add not a url x'))).toBe('invalid url');
|
||||||
|
expect(await svc.handle(ctx('#rss add ftp://a.example/rss x'))).toBe(
|
||||||
|
'invalid url',
|
||||||
|
);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(stored().list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add: rejects duplicate names case-insensitively without fetching', async () => {
|
||||||
|
stored().add({ name: 'MyFeed', url: 'https://old.example/rss', tags: [] });
|
||||||
|
|
||||||
|
const reply = await svc.handle(
|
||||||
|
ctx('#rss add https://a.example/rss myfeed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply).toBe('myfeed already exists - remove it first');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(stored().get('myfeed')?.url).toBe('https://old.example/rss'); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add: reports non-2xx and unparseable feeds, stores nothing', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
||||||
|
expect(await svc.handle(ctx('#rss add https://a.example/rss x'))).toBe(
|
||||||
|
'feed fetch failed (HTTP 404)',
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse('this is not xml'));
|
||||||
|
expect(await svc.handle(ctx('#rss add https://b.example/rss y'))).toBe(
|
||||||
|
'not a valid feed (no items)',
|
||||||
|
);
|
||||||
|
expect(stored().list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove: deletes case-insensitively, reports missing names', async () => {
|
||||||
|
stored().add({ name: 'Gone', url: 'https://g.example/rss', tags: [] });
|
||||||
|
expect(await svc.handle(ctx('#rss remove gone'))).toBe('removed gone');
|
||||||
|
expect(stored().list()).toEqual([]);
|
||||||
|
expect(await svc.handle(ctx('#rss remove nope'))).toBe(
|
||||||
|
'no source named nope',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags: no tags yet when empty, distinct sorted list otherwise', async () => {
|
||||||
|
expect(await svc.handle(ctx('#rss tags'))).toBe('no tags yet');
|
||||||
|
stored().add({
|
||||||
|
name: 'A',
|
||||||
|
url: 'https://a.example/rss',
|
||||||
|
tags: ['zeta', 'alpha'],
|
||||||
|
});
|
||||||
|
stored().add({
|
||||||
|
name: 'B',
|
||||||
|
url: 'https://b.example/rss',
|
||||||
|
tags: ['beta', 'alpha'],
|
||||||
|
});
|
||||||
|
expect(await svc.handle(ctx('#rss tags'))).toBe('alpha, beta, zeta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sources: no sources yet when empty, names otherwise', async () => {
|
||||||
|
expect(await svc.handle(ctx('#rss sources'))).toBe('no sources yet');
|
||||||
|
stored().add({ name: 'Bee', url: 'https://b.example/rss', tags: [] });
|
||||||
|
stored().add({ name: 'Aye', url: 'https://a.example/rss', tags: [] });
|
||||||
|
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get <name>: summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
|
||||||
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
llm.chat.mockResolvedValueOnce(
|
||||||
|
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
||||||
|
|
||||||
|
expect(reply).toEqual([
|
||||||
|
'Chips: EU funds foundries.',
|
||||||
|
'Park: Riverfront expansion approved.',
|
||||||
|
]);
|
||||||
|
const [messages, opts] = llm.chat.mock.calls[0];
|
||||||
|
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
|
||||||
|
expect(opts).toEqual({ maxTokens: 2500 });
|
||||||
|
const prompt = messages[0].content;
|
||||||
|
expect(prompt).toContain('exactly 3-5 lines');
|
||||||
|
expect(prompt).toContain('<short header>: <one or two short sentences>');
|
||||||
|
expect(prompt).toContain('at most 20 characters');
|
||||||
|
expect(prompt).toContain('No intro, no outro, no numbering');
|
||||||
|
expect(prompt).toContain('Alpha headline');
|
||||||
|
expect(prompt).toContain('Beta headline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get <name>: caps items at 5', async () => {
|
||||||
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
|
||||||
|
.map((c) => `${c}: item ${c}`)
|
||||||
|
.join('\n');
|
||||||
|
llm.chat.mockResolvedValueOnce(seven);
|
||||||
|
|
||||||
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
||||||
|
|
||||||
|
expect(reply).toEqual([
|
||||||
|
'a: item a',
|
||||||
|
'b: item b',
|
||||||
|
'c: item c',
|
||||||
|
'd: item d',
|
||||||
|
'e: item e',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get <name>: drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
|
||||||
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
llm.chat.mockResolvedValueOnce(
|
||||||
|
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
||||||
|
|
||||||
|
expect(reply).toEqual([
|
||||||
|
'Chips: EU funds foundries.',
|
||||||
|
'Park: expansion approved.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get <name>: hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
|
||||||
|
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
|
||||||
|
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
|
||||||
|
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
|
||||||
|
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
|
||||||
|
|
||||||
|
const reply = await svc.handle(ctx('#rss get myfeed'));
|
||||||
|
|
||||||
|
expect(Array.isArray(reply)).toBe(true);
|
||||||
|
const [cut, ok] = reply as string[];
|
||||||
|
expect(cut.length).toBeLessThanOrEqual(180);
|
||||||
|
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
|
||||||
|
expect(cut.endsWith(' ')).toBe(false);
|
||||||
|
expect(ok).toBe('Park: short line.'); // other lines untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
|
||||||
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
||||||
|
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
|
||||||
|
stored().add({ name: 'C', url: 'https://c.example/rss', tags: ['other'] }); // not matched by #tech
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
|
||||||
|
.mockRejectedValueOnce(new Error('down')); // B is dead
|
||||||
|
|
||||||
|
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
|
||||||
|
const reply = await svc.handle(ctx('#rss get #tech'));
|
||||||
|
|
||||||
|
// B's fetch rejected, yet the summary still went out from A's titles alone
|
||||||
|
expect(reply).toEqual(['Chips: only alpha survived.']);
|
||||||
|
expect(llm.chat).toHaveBeenCalledTimes(1);
|
||||||
|
const content = llm.chat.mock.calls[0][0][0].content;
|
||||||
|
expect(content).toContain('Alpha headline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get #tag: merges titles from every source carrying the tag', async () => {
|
||||||
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
||||||
|
stored().add({ name: 'B', url: 'https://b.example/rss', tags: ['tech'] });
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
|
||||||
|
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
|
||||||
|
|
||||||
|
llm.chat.mockResolvedValueOnce(
|
||||||
|
'Alpha: first headline.\nGamma: second headline.',
|
||||||
|
);
|
||||||
|
const reply = await svc.handle(ctx('#rss get #tech'));
|
||||||
|
|
||||||
|
expect(reply).toEqual([
|
||||||
|
'Alpha: first headline.',
|
||||||
|
'Gamma: second headline.',
|
||||||
|
]);
|
||||||
|
const content = llm.chat.mock.calls[0][0][0].content;
|
||||||
|
expect(content).toContain('Alpha headline');
|
||||||
|
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get: unknown name and tag get distinct error replies', async () => {
|
||||||
|
expect(await svc.handle(ctx('#rss get nope'))).toBe('no source named nope');
|
||||||
|
stored().add({ name: 'A', url: 'https://a.example/rss', tags: ['tech'] });
|
||||||
|
expect(await svc.handle(ctx('#rss get #nothing'))).toBe(
|
||||||
|
'no sources with tag nothing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
|
||||||
|
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
|
||||||
|
expect(
|
||||||
|
parseSummaryItems(
|
||||||
|
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
|
||||||
|
),
|
||||||
|
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 5 items', () => {
|
||||||
|
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
|
||||||
|
.map((c) => `${c}: item`)
|
||||||
|
.join('\n');
|
||||||
|
expect(parseSummaryItems(seven)).toHaveLength(5);
|
||||||
|
expect(parseSummaryItems(seven)[4]).toBe('e: item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when nothing is valid', () => {
|
||||||
|
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fitOneLine (one mesh packet per line)', () => {
|
||||||
|
it('passes through lines that already fit, including exactly 185 chars', () => {
|
||||||
|
const short = 'Chips: EU funds foundries.';
|
||||||
|
expect(fitOneLine(short)).toBe(short);
|
||||||
|
const exact = 'x'.repeat(185);
|
||||||
|
expect(fitOneLine(exact)).toBe(exact);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts over-long lines at a word boundary near 180', () => {
|
||||||
|
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
|
||||||
|
const cut = fitOneLine(long);
|
||||||
|
expect(cut.length).toBeLessThanOrEqual(180);
|
||||||
|
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
|
||||||
|
expect(cut.endsWith(' ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-cuts at 180 when there is no word boundary in reach', () => {
|
||||||
|
const long = 'w'.repeat(250);
|
||||||
|
expect(fitOneLine(long)).toBe('w'.repeat(180));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
// type-only both ways — no runtime import cycle between src/rss and src/commands
|
||||||
|
import type { CommandContext } from '../commands/command.service.js';
|
||||||
|
import { LlmService } from '../llm/llm.service.js';
|
||||||
|
import { parseFeed } from './rss.feed.js';
|
||||||
|
import { RssStore } from './rss.store.js';
|
||||||
|
|
||||||
|
// ponytail: hardcoded tunables, promote to env if the owner wants them adjustable
|
||||||
|
const FETCH_TIMEOUT_MS = 15000; // feed fetches — same budget as the opencode notifier's POSTs
|
||||||
|
const MAX_TITLES = 8; // "up to 8 latest item titles" per the design
|
||||||
|
const TAG_MAX_TOKENS = 50;
|
||||||
|
// The LLM server (qwen3.8-27b) emits reasoning_content and those tokens COUNT against max_tokens
|
||||||
|
// (verified: cap 150 → completion_tokens 150 of which 105 were reasoning, finish_reason 'length').
|
||||||
|
// Thinking length for this task is unbounded and uncontrollable server-side (/no_think,
|
||||||
|
// chat_template_kwargs.enable_thinking and thinking_budget are all ignored) — observed 32..1776
|
||||||
|
// tokens across runs. So the cap must be headroom for reasoning + ~80 content tokens:
|
||||||
|
// 2500 covers every observed run (worst case ≈42 s at ~60 tok/s, under LLM_TIMEOUT_MS=60 s).
|
||||||
|
const SUMMARY_MAX_TOKENS = 2500;
|
||||||
|
// Mesh fragments replies longer than ~190 chars into fire-and-forget packets that recipients
|
||||||
|
// often never get — each summary line must fit ONE packet on its own. The prompt asks for short
|
||||||
|
// headered lines; this is the hard backstop if the model ignores it (cut at a word boundary).
|
||||||
|
const MAX_LINE_CHARS = 185; // per-line one-packet threshold
|
||||||
|
const LINE_CUT_AT = 180; // where over-long lines get cut, snapped back to a word boundary
|
||||||
|
const MAX_ITEMS = 5; // cap on summary items sent as separate messages
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `#rss` command family: stored RSS sources (SQLite, shared MESH_DB_PATH file) with
|
||||||
|
* LLM-generated tags and on-demand headline summaries. Replies are short strings — or an array
|
||||||
|
* of lines for multi-message summaries, which CommandService sends one respond() per line.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RssService {
|
||||||
|
private readonly logger = new Logger(RssService.name);
|
||||||
|
private readonly store: RssStore;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly llm: LlmService,
|
||||||
|
db: DatabaseSync,
|
||||||
|
) {
|
||||||
|
this.store = new RssStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry point for the `#rss` prefix — always replies (errors included); `get` may reply with several lines. */
|
||||||
|
async handle(ctx: CommandContext): Promise<string | string[]> {
|
||||||
|
const words = ctx.text.trim().split(/\s+/);
|
||||||
|
switch (words[1]) {
|
||||||
|
case undefined: // bare `#rss`
|
||||||
|
case 'help':
|
||||||
|
return this.help();
|
||||||
|
case 'add':
|
||||||
|
return this.add(words.slice(2));
|
||||||
|
case 'remove':
|
||||||
|
return this.remove(words.slice(2).join(' '));
|
||||||
|
case 'tags':
|
||||||
|
return this.tags();
|
||||||
|
case 'sources':
|
||||||
|
return this.sources();
|
||||||
|
case 'get':
|
||||||
|
return this.get((words[2] ?? '').trim());
|
||||||
|
default:
|
||||||
|
return this.help(); // unknown subcommand → help
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private help(): string {
|
||||||
|
return [
|
||||||
|
'#rss add <url> <name> - add source with AI tags',
|
||||||
|
'#rss remove <name> - delete a source',
|
||||||
|
'#rss get <name|#tag> - summarize latest items',
|
||||||
|
'#rss tags - all distinct tags',
|
||||||
|
'#rss sources - all source names',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `#rss add <url> <name>` — name may contain spaces (everything after the url). */
|
||||||
|
private async add(args: string[]): Promise<string> {
|
||||||
|
const url = args[0];
|
||||||
|
const name = args.slice(1).join(' ').trim();
|
||||||
|
if (!url || !name) return 'usage: #rss add <url> <name>';
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return 'invalid url';
|
||||||
|
}
|
||||||
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
|
||||||
|
return 'invalid url';
|
||||||
|
if (this.store.exists(name))
|
||||||
|
return `${name} already exists - remove it first`;
|
||||||
|
|
||||||
|
let titles: string[];
|
||||||
|
try {
|
||||||
|
titles = await this.fetchLatestTitles(url);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
// fetchLatestTitles throws `HTTP <status>` on non-2xx, other errors carry their own message
|
||||||
|
return msg.startsWith('HTTP')
|
||||||
|
? `feed fetch failed (${msg})`
|
||||||
|
: 'not a valid feed';
|
||||||
|
}
|
||||||
|
if (!titles.length) return 'not a valid feed (no items)';
|
||||||
|
|
||||||
|
// AI tags — best effort: a source is still added when the LLM fails
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
const raw = await this.llm.chat(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Give 2-4 short lowercase tags for these RSS feed item titles. Reply with comma-separated tags only - no # symbols, no explanation:\n${titles.join('\n')}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ maxTokens: TAG_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
tags = sanitizeTags(raw);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[rss] tag generation failed for '${name}': ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.add({ name, url, tags });
|
||||||
|
return tags.length
|
||||||
|
? `added ${name} (tags: ${tags.join(', ')})`
|
||||||
|
: 'added ' + name + ' (tag generation failed - no tags)';
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(name: string): string {
|
||||||
|
if (!name) return 'usage: #rss remove <name>';
|
||||||
|
return this.store.remove(name)
|
||||||
|
? `removed ${name}`
|
||||||
|
: `no source named ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tags(): string {
|
||||||
|
const all = this.store.allTags();
|
||||||
|
return all.length ? all.join(', ') : 'no tags yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
private sources(): string {
|
||||||
|
const names = this.store.list().map((s) => s.name);
|
||||||
|
return names.length ? names.join(', ') : 'no sources yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `#rss get <name>` or `#rss get #<tag>` — replies with 3-5 separate one-packet lines. */
|
||||||
|
private async get(target: string): Promise<string | string[]> {
|
||||||
|
if (!target) return 'usage: #rss get <name|#tag>';
|
||||||
|
|
||||||
|
let urls: string[];
|
||||||
|
if (target.startsWith('#')) {
|
||||||
|
const tag = target.slice(1).toLowerCase();
|
||||||
|
if (!tag) return 'usage: #rss get <name|#tag>';
|
||||||
|
const matching = this.store.list().filter((s) => s.tags.includes(tag));
|
||||||
|
if (!matching.length) return `no sources with tag ${tag}`;
|
||||||
|
urls = matching.map((s) => s.url);
|
||||||
|
} else {
|
||||||
|
const source = this.store.get(target);
|
||||||
|
if (!source) return `no source named ${target}`;
|
||||||
|
urls = [source.url];
|
||||||
|
}
|
||||||
|
|
||||||
|
// one dead feed must not sink the whole summary — collect what answers
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
urls.map((u) => this.fetchLatestTitles(u)),
|
||||||
|
);
|
||||||
|
const titles: string[] = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled') titles.push(...r.value);
|
||||||
|
else
|
||||||
|
this.logger.warn(
|
||||||
|
`[rss] feed fetch failed: ${(r.reason as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!titles.length) return 'no items found';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reply = await this.llm.chat(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Summarize these RSS headlines into exactly 3-5 lines, one item per line, each in the format "<short header>: <one or two short sentences>" where the header is at most 20 characters (e.g. "Culture: Museum opens new wing"). No intro, no outro, no numbering - items only:\n${titles.join('\n')}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ maxTokens: SUMMARY_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
// each item goes out as its own mesh message — CommandService sends the array line by line;
|
||||||
|
// fewer than 3 valid lines is fine (the model is non-deterministic), we send what we got
|
||||||
|
return parseSummaryItems(reply).map(fitOneLine);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[rss] summary failed: ${(err as Error).message}`);
|
||||||
|
return 'summary failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a feed and return up to MAX_TITLES latest item titles; throws on non-2xx. */
|
||||||
|
private async fetchLatestTitles(url: string): Promise<string[]> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return parseFeed(await res.text())
|
||||||
|
.slice(0, MAX_TITLES)
|
||||||
|
.map((i) => i.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an LLM summary into per-line items: drop empty lines and lines without a `:` header separator, cap at MAX_ITEMS. */
|
||||||
|
export function parseSummaryItems(reply: string): string[] {
|
||||||
|
return reply
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && l.includes(':'))
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard-cut a single summary line at a word boundary near LINE_CUT_AT when it exceeds MAX_LINE_CHARS. */
|
||||||
|
export function fitOneLine(line: string): string {
|
||||||
|
if (line.length <= MAX_LINE_CHARS) return line;
|
||||||
|
const slice = line.slice(0, LINE_CUT_AT);
|
||||||
|
const at = slice.lastIndexOf(' ');
|
||||||
|
return (at > 0 ? slice.slice(0, at) : slice).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize LLM tag output: lowercase, no #, whitespace → '-', deduped. */
|
||||||
|
export function sanitizeTags(raw: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
const tag = part
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^#+/, '')
|
||||||
|
.replace(/\s+/g, '-');
|
||||||
|
if (tag) seen.add(tag);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { RssStore } from './rss.store.js';
|
||||||
|
|
||||||
|
describe('RssStore (real SQLite file)', () => {
|
||||||
|
let dir: string;
|
||||||
|
let store: RssStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'mesh-bot-rss-'));
|
||||||
|
const db = new DatabaseSync(join(dir, 'test.db'));
|
||||||
|
store = new RssStore(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and lists sources with tags round-tripped through the comma string', () => {
|
||||||
|
store.add({
|
||||||
|
name: 'Hacker News',
|
||||||
|
url: 'https://news.ycombinator.com/rss',
|
||||||
|
tags: ['tech', 'news'],
|
||||||
|
});
|
||||||
|
store.add({ name: 'LWN', url: 'https://lwn.net/headlines.rss', tags: [] });
|
||||||
|
|
||||||
|
expect(store.list()).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Hacker News',
|
||||||
|
url: 'https://news.ycombinator.com/rss',
|
||||||
|
tags: ['tech', 'news'],
|
||||||
|
createdAt: expect.any(Number),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LWN',
|
||||||
|
url: 'https://lwn.net/headlines.rss',
|
||||||
|
tags: [],
|
||||||
|
createdAt: expect.any(Number),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up and checks existence case-insensitively', () => {
|
||||||
|
store.add({ name: 'My Feed', url: 'https://example.com/rss', tags: ['a'] });
|
||||||
|
expect(store.get('my feed')?.url).toBe('https://example.com/rss');
|
||||||
|
expect(store.get('MY FEED')).toBeDefined();
|
||||||
|
expect(store.exists('mY fEeD')).toBe(true);
|
||||||
|
expect(store.exists('other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes case-insensitively and reports whether a row went', () => {
|
||||||
|
store.add({ name: 'Gone', url: 'https://example.com/gone', tags: [] });
|
||||||
|
expect(store.remove('gone')).toBe(true);
|
||||||
|
expect(store.list()).toEqual([]);
|
||||||
|
expect(store.remove('gone')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects all distinct tags across sources, sorted', () => {
|
||||||
|
store.add({
|
||||||
|
name: 'A',
|
||||||
|
url: 'https://a.example/rss',
|
||||||
|
tags: ['zeta', 'alpha'],
|
||||||
|
});
|
||||||
|
store.add({
|
||||||
|
name: 'B',
|
||||||
|
url: 'https://b.example/rss',
|
||||||
|
tags: ['beta', 'alpha'],
|
||||||
|
});
|
||||||
|
expect(store.allTags()).toEqual(['alpha', 'beta', 'zeta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists across a second connection to the same file (the production shape)', () => {
|
||||||
|
store.add({ name: 'Durable', url: 'https://d.example/rss', tags: ['x'] });
|
||||||
|
const db2 = new DatabaseSync(join(dir, 'test.db'));
|
||||||
|
expect(new RssStore(db2).get('durable')?.tags).toEqual(['x']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
/** A stored RSS source; tags are a comma-separated string in the DB, split here. */
|
||||||
|
export interface RssSource {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
tags: string[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent — safe to call on every open (the table lives in the shared MESH_DB_PATH file). */
|
||||||
|
export function initRssTable(db: DatabaseSync): void {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS rss_sources (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRUD for the rss_sources table. Names are matched case-insensitively everywhere. */
|
||||||
|
export class RssStore {
|
||||||
|
constructor(private readonly db: DatabaseSync) {
|
||||||
|
initRssTable(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): RssSource[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT name, url, tags, created_at FROM rss_sources ORDER BY name',
|
||||||
|
)
|
||||||
|
.all() as {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
tags: string;
|
||||||
|
created_at: number;
|
||||||
|
}[];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
tags: splitTags(r.tags),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive lookup by name. */
|
||||||
|
get(name: string): RssSource | undefined {
|
||||||
|
const row = this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT name, url, tags, created_at FROM rss_sources WHERE name = ? COLLATE NOCASE',
|
||||||
|
)
|
||||||
|
.get(name) as
|
||||||
|
| { name: string; url: string; tags: string; created_at: number }
|
||||||
|
| undefined;
|
||||||
|
return (
|
||||||
|
row && {
|
||||||
|
name: row.name,
|
||||||
|
url: row.url,
|
||||||
|
tags: splitTags(row.tags),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive duplicate check. */
|
||||||
|
exists(name: string): boolean {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT 1 FROM rss_sources WHERE name = ? COLLATE NOCASE')
|
||||||
|
.get(name);
|
||||||
|
return row !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(source: { name: string; url: string; tags: string[] }): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO rss_sources (name, url, tags, created_at) VALUES (?, ?, ?, ?)',
|
||||||
|
)
|
||||||
|
.run(source.name, source.url, joinTags(source.tags), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive delete; true when a row was removed. */
|
||||||
|
remove(name: string): boolean {
|
||||||
|
const result = this.db
|
||||||
|
.prepare('DELETE FROM rss_sources WHERE name = ? COLLATE NOCASE')
|
||||||
|
.run(name);
|
||||||
|
return Number(result.changes) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All distinct tags across sources, sorted. */
|
||||||
|
allTags(): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const source of this.list()) {
|
||||||
|
for (const tag of source.tags) set.add(tag);
|
||||||
|
}
|
||||||
|
return [...set].sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTags(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinTags(tags: string[]): string {
|
||||||
|
return tags.join(',');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user