From 024dabda972bc5cdd59438a5faff075516ecec35 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 03:35:50 +0200 Subject: [PATCH] =?UTF-8?q?Outbound=20notification=20webhook=20(MESH=5FNOT?= =?UTF-8?q?IFY=5FURL)=20on=20RX=20text=20=E2=80=94=20issue=20#17=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CHANGELOG.md | 6 ++ README.md | 2 + package.json | 2 +- src/mesh/mesh.service.spec.ts | 112 +++++++++++++++++++++++++++++++++- src/mesh/mesh.service.ts | 19 ++++++ 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6fb544..dc3d792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; no retries, no response handling. 3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index 91325dc..55f20a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.11.0 — 2026-09-25 01:30 + +- Outbound notification webhook (#17): when an inbound RX `TEXT_MESSAGE_APP` packet arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`, read at construction exactly like `MESH_SEND_CHANNEL`), the service fires a fire-and-forget HTTP POST to `MESH_NOTIFY_URL` with JSON body `{text, from}` (`from` omitted when absent) — no retries, no queue, no response handling; failures only log a warn. Unset or empty `MESH_NOTIFY_URL` disables the feature entirely (no fetch at all). Uses Node 22's global `fetch` — zero new dependencies. +- Tests: three new specs in a `MeshService notify webhook` block (global fetch stubbed via `vi.stubGlobal`): rx text on channel 3 → exactly one POST with the URL, `content-type: application/json`, and body `{text, from}`; rx text on another channel → captured in history but no call; `MESH_NOTIFY_URL` unset or empty → no call. All 47 pre-existing specs stay green (50 total). +- Docs: README env-var list (+2 vars), AGENTS.md API/env note. + ## v0.10.0 — 2026-09-25 00:30 - Message history is now persisted to SQLite via the stdlib `node:sqlite` `DatabaseSync` (zero new dependencies). DB file path from env `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created); poller interval from env `MESH_POLL_MS` (default `1000` ms — "frequent pooling, configurable"). Schema: one table `messages(id INTEGER PRIMARY KEY, dir TEXT NOT NULL, to_node INTEGER, from_node INTEGER, text TEXT NOT NULL, channel INTEGER NOT NULL, at INTEGER NOT NULL, status TEXT, ack_error TEXT)`, keyed by packet id. diff --git a/README.md b/README.md index 8657149..32b04c6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ curl -X POST http://localhost:3000/mcp \ - `MESH_PORT` (default `4403`) — bridge port - `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel) +- `MESH_NOTIFY_URL` — outbound notification webhook URL; when set (non-empty), an inbound rx text message arriving on channel `MESH_NOTIFY_CHANNEL` fires a fire-and-forget HTTP POST with JSON body `{text, from}` (`from` omitted when absent). Unset or empty = disabled (no fetch at all) +- `MESH_NOTIFY_CHANNEL` (default `3`) — channel index that triggers the notify webhook - `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation) - `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000 - `PORT` (default `3000`) — HTTP listen port diff --git a/package.json b/package.json index 8c57d45..3858330 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.10.0", + "version": "0.11.0", "description": "", "author": "", "private": true, diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index ebcab9b..e43cb55 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -1,7 +1,7 @@ import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; import { DatabaseSync } from 'node:sqlite'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BROADCAST_ADDR, extractFrames, frame } from './codec.js'; import { KAMIL_NUM, @@ -562,6 +562,116 @@ describe('MeshService message history', () => { }); }); +describe('MeshService notify webhook', () => { + const fetchMock = vi.fn(async (..._args: unknown[]) => undefined); + + beforeEach(() => fetchMock.mockClear()); + + it('POSTs {text, from} to MESH_NOTIFY_URL when an rx text lands on the notify channel (default 3)', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + process.env.MESH_NOTIFY_URL = 'http://notify.example/hook'; + vi.stubGlobal('fetch', fetchMock); + const svc = new MeshService(); + try { + svc.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 1 && svc.status().connected, + ); + + bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); + + await waitFor(() => fetchMock.mock.calls.length === 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://notify.example/hook'); + const opts = init as { + method?: string; + headers?: Record; + body?: string; + }; + expect(opts.method).toBe('POST'); + expect(opts.headers?.['content-type']).toBe('application/json'); + expect(JSON.parse(String(opts.body))).toEqual({ + text: 'ping', + from: KAMIL_NUM, + }); + } finally { + vi.unstubAllGlobals(); + delete process.env.MESH_NOTIFY_URL; + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('does not call fetch for rx texts on other channels', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + process.env.MESH_NOTIFY_URL = 'http://notify.example/hook'; + vi.stubGlobal('fetch', fetchMock); + const svc = new MeshService(); + try { + svc.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 1 && svc.status().connected, + ); + + bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2)); + + // the message is captured in history — but no webhook fires off-channel + await waitFor(() => svc.messages().length === 1); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + delete process.env.MESH_NOTIFY_URL; + svc.onModuleDestroy(); + bridge.close(); + } + }); + + it('never calls fetch when MESH_NOTIFY_URL is unset or empty', async () => { + const bridge = await startBridge(); + process.env.MESH_HOST = '127.0.0.1'; + process.env.MESH_PORT = String(bridge.port); + delete process.env.MESH_NOTIFY_URL; // unset → disabled + vi.stubGlobal('fetch', fetchMock); + const svc = new MeshService(); + try { + 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); + expect(fetchMock).not.toHaveBeenCalled(); + + // empty string → also disabled (a fresh service re-reads the env) + process.env.MESH_NOTIFY_URL = ''; + const svc2 = new MeshService(); + try { + svc2.onModuleInit(); + await waitFor( + () => bridge.sockets.length === 2 && svc2.status().connected, + ); + + bridge.sockets[1].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3)); + await waitFor(() => svc2.messages().length === 1); + } finally { + delete process.env.MESH_NOTIFY_URL; + svc2.onModuleDestroy(); + } + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + svc.onModuleDestroy(); + bridge.close(); + } + }); +}); + describe('MeshService injectMessage', () => { it('injects rx records that appear newest-first in messages()', () => { const svc = new MeshService(); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index d476b6b..75c7b8c 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -157,6 +157,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { process.env.MESH_SEND_CHANNEL ?? '3', 10, ); + // outbound notification webhook: fire-and-forget POST when an rx text lands on the notify channel; empty URL = disabled + private readonly notifyUrl = process.env.MESH_NOTIFY_URL ?? ''; + private readonly notifyChannel = parseInt( + process.env.MESH_NOTIFY_CHANNEL ?? '3', + 10, + ); // how often the poller re-upserts the history buffer into SQLite (ms) private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10); @@ -599,6 +605,19 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { channel: pkt.channel ?? 0, at: Date.now(), }); + if (this.notifyUrl && (pkt.channel ?? 0) === this.notifyChannel) { + const body: Record = { text }; + if (pkt.from !== undefined) body.from = pkt.from; + void fetch(this.notifyUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }).catch((err: unknown) => + this.logger.warn( + 'notify webhook failed: ' + (err as Error).message, + ), + ); + } } else if (decoded && decoded.portnum === PORT_POSITION_APP) { // live position update: the Position is serialized in Data.payload let pos: Proto.Mesh.Position | undefined;