From 70be8bdaa743b73627e34483d63d567f8c879b61 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 26 Sep 2026 10:52:18 +0000 Subject: [PATCH] =?UTF-8?q?Add=20#help=20command=20=E2=80=94=20short=20sin?= =?UTF-8?q?gle-message=20list=20of=20all=20commands=20(v0.21.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request: 'now add #help with short list of all command'. Registered in the CommandService constructor next to #ping; returns a SINGLE multi-line string (one packet, ~203 chars) — not an array, which would drip-feed one spaced packet per line through the v0.21.5 MESH_MULTI_SEND_DELAY_MS loop. Also hardened the v0.21.5 multi-send-spacing spec: its wall-clock assertion ('first send < 8 ms after t0') flaked under full-suite load — replaced with fake timers, which assert deterministically that line 1 is out before any time elapses and lines 2-3 land exactly one delay apart. --- AGENTS.md | 2 +- CHANGELOG.md | 5 ++++ package.json | 2 +- src/commands/command.service.spec.ts | 35 ++++++++++++++++++++-------- src/commands/command.service.ts | 11 +++++++++ 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e26067f..f2ebea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,5 +27,5 @@ - 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`. -- 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, spaced `MESH_MULTI_SEND_DELAY_MS` ms apart (default 5000) so each packet has time to land in RF — 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 ` (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 `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `: ` 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). +- 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, spaced `MESH_MULTI_SEND_DELAY_MS` ms apart (default 5000) so each packet has time to land in RF — or void = no reply). First commands: `#ping` → replies `#pong!`; `#help` → short single-message list of all available commands. 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 ` (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 `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `: ` 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 180000, via `AbortSignal.timeout`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1724863..59b478c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.21.6 — 2026-09-26 10:37 + +- New `#help` command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — `#ping`, the `#rss help/add/remove/get/sources/tags` family. Registered in the CommandService constructor next to `#ping`. +- Tests: 173 → 174 — `#help` dispatches through respond() exactly once with text containing both `#ping` and `#rss`. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.) + ## v0.21.5 — 2026-09-26 10:26 - Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land. diff --git a/package.json b/package.json index 9dfc7d9..2365922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.21.5", + "version": "0.21.6", "description": "", "author": "", "private": true, diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index f216cf3..948f553 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -41,6 +41,15 @@ describe('CommandService.onRx', () => { expect(mesh.sendText).not.toHaveBeenCalled(); }); + it('replies #help with a short single-message list of all commands', async () => { + const [svc, mesh] = makeService(); + await svc.onRx(2, '#help', 42, 7); + expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line + const text = mesh.respond.mock.calls[0][1]; + expect(text).toContain('#ping'); + expect(text).toContain('#rss'); + }); + it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => { const [svc, mesh] = makeService(); await svc.onRx(2, '#ping', 42); // from present but no id — defensive path @@ -126,26 +135,32 @@ describe('CommandService.onRx', () => { }); it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => { - process.env.MESH_MULTI_SEND_DELAY_MS = '10'; // short so the test stays fast - const sentAt: number[] = []; + process.env.MESH_MULTI_SEND_DELAY_MS = '50'; // short so the test stays fast + let sends = 0; const respond = vi.fn(async () => { - sentAt.push(Date.now()); + sends++; return { id: 1 }; }); + vi.useFakeTimers(); try { const [svc, mesh] = makeService(undefined, respond); svc.register('multi3', () => ['a: one', 'b: two', 'c: three']); - const t0 = Date.now(); - await svc.onRx(2, '#multi3', 42, 9); + const p = svc.onRx(2, '#multi3', 42, 9); + // flush microtasks without advancing time — the first line must already be out (no delay before it) + await vi.advanceTimersByTimeAsync(0); + expect(sends).toBe(1); + // each subsequent line waits exactly MESH_MULTI_SEND_DELAY_MS after the previous one landed + await vi.advanceTimersByTimeAsync(50); + expect(sends).toBe(2); + await vi.advanceTimersByTimeAsync(50); + expect(sends).toBe(3); + + await p; expect(mesh.respond).toHaveBeenCalledTimes(3); - // the first line goes out immediately (no delay before it) — only microtask-scale overhead between t0 and the first send - expect(sentAt[0] - t0).toBeLessThan(8); - // each subsequent line waits at least MESH_MULTI_SEND_DELAY_MS after the previous one landed - expect(sentAt[1] - sentAt[0]).toBeGreaterThanOrEqual(10); - expect(sentAt[2] - sentAt[1]).toBeGreaterThanOrEqual(10); } finally { + vi.useRealTimers(); delete process.env.MESH_MULTI_SEND_DELAY_MS; } }); diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts index cf16685..c0a4314 100644 --- a/src/commands/command.service.ts +++ b/src/commands/command.service.ts @@ -43,6 +43,17 @@ export class CommandService { rss?: RssService, ) { this.register('ping', () => '#pong!'); + // single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line + this.register('help', () => + [ + '#ping - alive check', + '#rss help - rss subcommands', + '#rss add - add feed with AI tags', + '#rss remove - drop feed', + '#rss get - news summary', + '#rss sources / #rss tags - list feeds/tags', + ].join('\n'), + ); if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx)); }