From 715cb552675dc7aea9bef0d505eb692c8c166430 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 25 Sep 2026 01:51:54 +0000 Subject: [PATCH] Fire notify webhook for injected messages (channel 0) --- AGENTS.md | 2 +- CHANGELOG.md | 7 ++++++ README.md | 2 +- docker-compose.yml | 2 ++ package.json | 2 +- src/mesh/mesh.service.spec.ts | 45 +++++++++++++++++++++++++++++++++++ src/mesh/mesh.service.ts | 31 ++++++++++++++---------- 7 files changed, 75 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc3d792..7db54f1 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. 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. +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. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. 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 55f20a1..3f4a8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.11.1 — 2026-09-25 01:49 + +- Injected messages now fire the outbound notify webhook too: `injectMessage` calls the same extracted private helper (`notifyRx(channel, text, from?)`) as the bridge RX path (which lost its inline block), with channel 0 — so a synthetic rx record triggers the webhook only when `MESH_NOTIFY_CHANNEL` is set to `0`; no forced semantics change, default stays `3`. +- `docker-compose.yml`: passthrough for `MESH_NOTIFY_URL` / `MESH_NOTIFY_CHANNEL` via `${VAR:-default}` interpolation from `.env` (URL empty = disabled, channel default 3). +- Docs: README env-var note + AGENTS.md webhook line. +- Tests: two new specs in the existing `MeshService notify webhook` block — inject with `from` → exactly one POST `{text, from}`; without → body omits `from`. All 50 pre-existing specs stay green (52 total). + ## 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. diff --git a/README.md b/README.md index 32b04c6..58363e3 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ 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_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). Synthetic injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. 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 diff --git a/docker-compose.yml b/docker-compose.yml index a0bbda5..fccc94a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: MESH_PORT: '4403' MESH_DB_PATH: ./data/mesh-bot.db MESH_POLL_MS: '1000' + MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-} + MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}' volumes: - mesh_data:/app/data restart: unless-stopped diff --git a/package.json b/package.json index 3858330..2262250 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.11.0", + "version": "0.11.1", "description": "", "author": "", "private": true, diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index e43cb55..add16ae 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -670,6 +670,51 @@ describe('MeshService notify webhook', () => { bridge.close(); } }); + + it('fires the webhook for an injected message when MESH_NOTIFY_CHANNEL matches its channel (0)', async () => { + process.env.MESH_NOTIFY_URL = 'http://notify.example/hook'; + process.env.MESH_NOTIFY_CHANNEL = '0'; + vi.stubGlobal('fetch', fetchMock); + const svc = new MeshService(); + try { + svc.injectMessage('hi', 42); + + 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: 'hi', from: 42 }); + } finally { + vi.unstubAllGlobals(); + delete process.env.MESH_NOTIFY_URL; + delete process.env.MESH_NOTIFY_CHANNEL; + } + }); + + it('omits `from` in the webhook body when an injected message has no sender', async () => { + process.env.MESH_NOTIFY_URL = 'http://notify.example/hook'; + process.env.MESH_NOTIFY_CHANNEL = '0'; + vi.stubGlobal('fetch', fetchMock); + const svc = new MeshService(); + try { + svc.injectMessage('hi'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + const opts = init as { body?: string }; + expect(JSON.parse(String(opts.body))).toEqual({ text: 'hi' }); + } finally { + vi.unstubAllGlobals(); + delete process.env.MESH_NOTIFY_URL; + delete process.env.MESH_NOTIFY_CHANNEL; + } + }); }); describe('MeshService injectMessage', () => { diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 75c7b8c..88eca76 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -426,9 +426,26 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { at: Date.now(), }; this.pushHistory(rec); + // injected records ride channel 0 — the webhook fires only when MESH_NOTIFY_CHANNEL is set to match + this.notifyRx(0, text, from); return { id: rec.id }; } + /** Fire-and-forget notify-webhook POST for an rx text on `channel` (no-op unless URL is set and channels match). */ + private notifyRx(channel: number, text: string, from?: number): void { + if (this.notifyUrl && channel === this.notifyChannel) { + const body: Record = { text }; + if (from !== undefined) body.from = 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), + ); + } + } + /** * Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics * (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them. @@ -605,19 +622,7 @@ 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, - ), - ); - } + this.notifyRx(pkt.channel ?? 0, text, pkt.from); } else if (decoded && decoded.portnum === PORT_POSITION_APP) { // live position update: the Position is serialized in Data.payload let pos: Proto.Mesh.Position | undefined;