# mesh-bot Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, sends `want_config_id` on connect so the firmware streams its initial sync data, parses inbound protobuf frames, logs received text messages, and exposes HTTP endpoints to send text. ## Local run ```sh npm i npm run start:dev ``` ## Docker ```sh docker compose up -d --build ``` Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`. ## Endpoints 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 - `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 - `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 - `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at `/docs-json`. ```sh 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 http://localhost:3000/api/channels curl http://localhost:3000/api/device curl http://localhost:3000/api/messages curl -X POST http://localhost:3000/api/messages -H 'content-type: application/json' -d '{"text":"external trigger"}' curl http://localhost:3000/api/telemetry curl http://localhost:3000/api/nodes curl http://localhost:3000/api/nodes/kamil ``` `to` omitted, `"!b"`, or `"broadcast"` (case-insensitive) sends to the broadcast address `0xffffffff`; anything else is a node number or name. DMs are unicast and wantAck-tracked via `GET /api/messages`; broadcasts stay pending because firmware strips want_ack before TX. All sends always target channel index 3 (`test`) — never the primary channel. Override with `MESH_SEND_CHANNEL`. ## MCP server 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: - `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 - `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 - `get_messages` — message history, newest first (last 100, tx records carry delivery status) - `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; appears in `get_messages` like any other rx record - `get_telemetry` — telemetry history, newest first (last 100): device metrics and environment sensor readings from any node; `kind` is the Telemetry oneof case name - `device_info` — device info from the initial sync: node number, firmware version, metadata and per-section config/module-config (key material redacted as `"REDACTED"`) Point any MCP client at it (Streamable HTTP URL): ```json { "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } } ``` ```sh curl -X POST http://localhost:3000/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ## Automated weather reports Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Open-Meteo](https://open-meteo.com/) (free, no API key) and broadcasts a one-line report on the pinned send channel (index 3, `test`) — e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`. A failed fetch or send only logs a warn; it never crashes the app. - `WEATHER_CRON` (default `'0 * * * *'`) — cron expression for the report schedule - `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo - `WEATHER_CITY` (default `"Lublin"`) — city name in the report line ## 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. Config lives in `.env` (gitignored) and is passed via compose environment — credentials are never committed to the repo: - `OPENCODE_API` (default `http://172.24.0.1:9898`) — opencode API base URL as seen from the bot container (dev_net bridge → host; opencode binds 0.0.0.0 on the host) - `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match) - `OPENCODE_SESSION` (optional, default empty) — pins a session ID; when set, session discovery is skipped. Empty/unset → the most recently updated session of the resolved workspace is used per request - `OPENCODE_USER` / `OPENCODE_PASS` — Basic auth credentials; missing/empty **disables** the notifier (one startup log line, no-op) instead of failing ## Env vars - `MESH_HOST` (default `192.168.1.4`) — bridge host - `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_CHANNEL` (default `3`) — channel index whose rx texts trigger the opencode notifier (see "Opencode notifier"; synthetic injected messages, channel 0, trigger it too when set to match; messages from our own node are never notified) - `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 Note: the serial bridge accepts only ONE client at a time — if another app is connected to the radio, this bot will not get a connection.