Compare commits

..
1 Commits
Author SHA1 Message Date
kamilkrzywda 3d0e369230 Notify webhook also fires for injected messages (channel match) + compose env passthroughs (#21)
CI / build + unit (push) Successful in 35s
2026-09-25 03:56:53 +02:00
7 changed files with 75 additions and 16 deletions
+1 -1
View File
@@ -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"?: <node number | node name | "!b" | "broadcast"> }` — 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: {<section>: ...}, moduleConfig: {<module>: ...} }` — 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"?: <node number> }`, 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"?: <node number | node name | "!b" | "broadcast"> }` — 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: {<section>: ...}, moduleConfig: {<module>: ...} }` — 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"?: <node number> }`, 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
+7
View File
@@ -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.
+1 -1
View File
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.11.0",
"version": "0.11.1",
"description": "",
"author": "",
"private": true,
+45
View File
@@ -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<string, string>;
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', () => {
+18 -13
View File
@@ -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<string, unknown> = { 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<string, unknown> = { 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;