2026-09-25 06:41:49 +00:00

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

npm i
npm run start:dev

Docker

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"?: <node number | node name | "!b" | "broadcast"> }, 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
  • 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: {<section>: ...}, moduleConfig: {<module>: ...} }. 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"?: <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; 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.

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?) — send a text message; to resolves like POST /api/send (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); 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):

{ "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } }
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"}'

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_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. Messages from our own node are never notified (self-echo filter). 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

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.

S
Description
Meshtastic bot
Readme
1.1 MiB
Languages
TypeScript 99.9%
Dockerfile 0.1%