diff --git a/AGENTS.md b/AGENTS.md index c045f89..34d2eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ ## 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"?: , "replyId"?: }` — 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. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). 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: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node ]: "" — please react to it.` (the `from node` part omitted when absent), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). -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?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; 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.). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — 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. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `POST /api/respond` (body `{ "messageId": , "content": "..." }`, returns `{ id }`; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries `replyId = messageId` so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), `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` and `to`, 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: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when the notify channel is set to match. Messages from our own node are never notified (self-echo filter). +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?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; errors surface as tool errors with the same messages), `respond(messageId, content)` (reply to an earlier message by packet id from `get_messages`; routing is automatic — rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with replyId = messageId so the recipient app renders it as a threaded quote; invalid args or an unknown id are tool errors), `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 (`respond` rides the record's channel). 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 b6316c8..2dc4e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.15.0 — 2026-09-25 15:40 + +- New `POST /api/respond` endpoint and MCP `respond(messageId, content)` tool: reply to an earlier message by packet id with automatic routing — the caller never thinks about where it goes. rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their own destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. Returns `{ id }`; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down; MCP tool errors carry the same messages. +- Captured rx history records now persist their destination (`to: pkt.to`) — no schema change needed: the `to_node` column already existed for tx rows and flush/seed already round-tripped it, so captured packets simply fill it in too. This is what lets a reply tell a DM from a broadcast original. +- The opencode notifier wrapper now includes the packet id: msg = `New mesh message (id )[ from node ]: "" — please react to it.` — the id is the rx packet's id, or the generated record id for injected messages; the `from node` part is still omitted when absent. +- Tests: 88 → 96 specs. New: captured-rx row persists `to_node`; six `respond` service specs (rx DM → directed send back to sender on the original channel with wire-level replyId, broadcast original → broadcast reply, tx record → its destination/channel, SQLite fallback when the record is outside the in-memory buffer, 404 memory+DB miss, 400 validation matrix); MCP `respond` wiring spec (routing + unknown-id/out-of-range/empty-content tool errors) and the tool list grows to ten. +- Docs: README gains the endpoint bullet, a curl example, the MCP tool entry and the updated notifier wrapper format; AGENTS.md endpoints/MCP/notifier lines kept in sync with the controller. + ## v0.14.0 — 2026-09-25 14:11 - The standalone Python notify forwarder (`ops/forwarder`, compose service `forwarder`) is gone: its logic now lives in-process as a new `src/opencode` module — an `@Injectable()` `OpencodeService` registered in `MeshModule` and injected into `MeshService`. When an RX text lands on channel `MESH_NOTIFY_CHANNEL` (default 3), `MeshService.notifyRx()` calls `void this.opencode.notify(text, from)` instead of the old webhook POST; the channel gate and self-echo filter stay in `MeshService`, everything else (workspace/session resolution, message POST, retries) moved into the service. diff --git a/README.md b/README.md index 69ed200..2449a66 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ All API endpoints live under the `/api` prefix: - `GET /api/status` — `{ connected, myNodeNum, host, port }` - `POST /api/send` — body `{ "text": "...", "to"?: , "replyId"?: }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet +- `POST /api/respond` — body `{ "messageId": , "content": "..." }`, returns `{ id }`. Reply to an earlier message by packet id (from `GET /api/messages`) with automatic routing: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast. The reply always rides the record's channel and carries `replyId = messageId`, so the recipient app renders it as a threaded quote of that packet. 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down - `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes) - `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect -- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows +- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from` and `to`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. Backed by SQLite: the in-memory buffer (last 100) is seeded from the database on startup and re-upserted every second, so history survives process restarts and bridge reconnects; the database keeps the newest 1000 rows - `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; appears in `GET /api/messages` like any other rx message - `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect - `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo @@ -42,6 +43,7 @@ Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at curl http://localhost:3000/api/status curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}' curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}' +curl -X POST http://localhost:3000/api/respond -H 'content-type: application/json' -d '{"messageId":123456789,"content":"on my way"}' curl http://localhost:3000/api/channels curl http://localhost:3000/api/device curl http://localhost:3000/api/messages @@ -64,10 +66,12 @@ channel. Override with `MESH_SEND_CHANNEL`. The bot also exposes an MCP (Model Context Protocol) endpoint at `POST /mcp` (outside the `/api` prefix), served in-process over the SDK's Streamable HTTP transport in stateless mode. It maps 1:1 to the REST -endpoints — no new logic, same channel pinning: +endpoints — no new logic; sends ride the pinned channel like their REST +counterparts (`respond` rides the original message's channel): - `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`) - `send_message(text, to?, replyId?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optional `replyId` makes the message render as a threaded reply/quote of that packet id from `get_messages`; returns `{ id }`; unknown/ambiguous targets are tool errors +- `respond(messageId, content)` — reply to an earlier message by packet id (from `get_messages`) with automatic routing: rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with `replyId = messageId` (threaded quote); returns `{ id }`; invalid args or an unknown id are tool errors - `list_channels` — device channels (keys redacted) - `list_nodes` — known nodes as lean summaries - `get_node(id)` — one node by number or name; tool error when missing/ambiguous @@ -99,7 +103,7 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope ## Opencode notifier -In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message[ from node ]: "" — please react to it.` (the `from node` part omitted when there is no sender). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. +In-process service (`src/opencode`, no separate compose service): when an rx text message arrives on `MESH_NOTIFY_CHANNEL`, it pushes it to the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the notifier resolves it dynamically per request via the opencode API: `GET {OPENCODE_API}/api/workspaces` → match by `path == OPENCODE_WORKSPACE_PATH` (fallback name `mesh-bot`, status `ready` preferred when several match) → `GET {proxyPath}/session` → most recently updated session (`time.updated`; first entry if absent). No caching — messages are rare. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when there is no sender; the packet id is the rx packet's id, or the generated record id for injected messages). opencode accepts the message immediately but holds the HTTP response until the agent turn finishes, so a 15 s read timeout on that POST means **delivered** — treated as success, never retried; workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo: diff --git a/package.json b/package.json index 0ff71bb..4e7b4d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.14.0", + "version": "0.15.0", "description": "", "author": "", "private": true,