Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847c3d47d0 | ||
|
|
1b768b14a7 |
@@ -27,5 +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).
|
- 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 <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 180000, via `AbortSignal.timeout`).
|
- 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`).
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.5 — 2026-09-26 10:26
|
||||||
|
|
||||||
|
- Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
|
||||||
|
- Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin `MESH_MULTI_SEND_DELAY_MS=0` in `beforeEach` so they stay fast.
|
||||||
|
|
||||||
## v0.21.4 — 2026-09-26 09:51
|
## v0.21.4 — 2026-09-26 09:51
|
||||||
|
|
||||||
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
|
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.21.4",
|
"version": "0.21.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeService(
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
|
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
|
||||||
|
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CommandService.onRx', () => {
|
describe('CommandService.onRx', () => {
|
||||||
@@ -124,6 +125,31 @@ describe('CommandService.onRx', () => {
|
|||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
|
||||||
|
process.env.MESH_MULTI_SEND_DELAY_MS = '10'; // short so the test stays fast
|
||||||
|
const sentAt: number[] = [];
|
||||||
|
const respond = vi.fn(async () => {
|
||||||
|
sentAt.push(Date.now());
|
||||||
|
return { id: 1 };
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
delete process.env.MESH_MULTI_SEND_DELAY_MS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
|
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
svc.register('multi', () => ['one: first item', 'two: second item']);
|
svc.register('multi', () => ['one: first item', 'two: second item']);
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export class CommandService {
|
|||||||
process.env.COMMAND_CHANNEL ?? '2',
|
process.env.COMMAND_CHANNEL ?? '2',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
|
||||||
|
private readonly multiSendDelayMs = parseInt(
|
||||||
|
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
|
||||||
|
10,
|
||||||
|
);
|
||||||
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
|
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
|
||||||
private readonly commands = new Map<string, CommandHandler>();
|
private readonly commands = new Map<string, CommandHandler>();
|
||||||
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
|
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
|
||||||
@@ -83,8 +88,15 @@ export class CommandService {
|
|||||||
try {
|
try {
|
||||||
const reply = await handler({ text: trimmed, from, id });
|
const reply = await handler({ text: trimmed, from, id });
|
||||||
if (reply === undefined || reply === null) return; // void = no reply
|
if (reply === undefined || reply === null) return; // void = no reply
|
||||||
// an array reply sends one message per line, all threaded to the same trigger packet id
|
// an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
|
||||||
for (const line of Array.isArray(reply) ? reply : [reply]) {
|
const lines = Array.isArray(reply) ? reply : [reply];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (i > 0 && this.multiSendDelayMs > 0) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.multiSendDelayMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const line = lines[i];
|
||||||
if (id !== undefined && this.mesh) {
|
if (id !== undefined && this.mesh) {
|
||||||
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
|
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
|
||||||
await this.mesh.respond(id, line);
|
await this.mesh.respond(id, line);
|
||||||
|
|||||||
Reference in New Issue
Block a user