9.4 KiB
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 likeGET /api/nodes/:id(exact long/short name, then unique substring) — unknown or ambiguous targets are a 400GET /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 reconnectGET /api/messages— message history, newest first: inbound text (dir:'rx', withfrom) and our sends (dir:'tx', withto+status). tx records startpendingand move todelivered/failed(with human-readableackError) when the routing ACK/NACK arrives; broadcasts staypendingbecause 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 rowsPOST /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 inGET /api/messageslike any other rx messageGET /api/telemetry— telemetry history, newest first: liveTELEMETRY_APPpackets from any node as{ from, at /* epoch ms */, kind, data }[].kindis themeshtastic.Telemetryoneof case name (deviceMetrics,environmentMetrics,airQualityMetrics,powerMetrics,localStats,healthMetrics,hostMetricsortrafficManagementStats) anddatacarries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnectGET /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,lastHeardis epoch seconds (null = never heard),positiononly when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfoGET /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;toresolves likePOST /api/send(omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns{ id }; unknown/ambiguous targets are tool errorslist_channels— device channels (keys redacted)list_nodes— known nodes as lean summariesget_node(id)— one node by number or name; tool error when missing/ambiguousget_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 inget_messageslike any other rx recordget_telemetry— telemetry history, newest first (last 100): device metrics and environment sensor readings from any node;kindis the Telemetry oneof case namedevice_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"}'
Automated weather reports
Hourly cron job (src/weather): fetches current conditions for Lublin from Open-Meteo (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 scheduleWEATHER_LAT(default51.2465) /WEATHER_LON(default22.5684) — coordinates queried from Open-MeteoWEATHER_CITY(default"Lublin") — city name in the report line
Notify forwarder
Second compose service (ops/forwarder, python:3.12-slim, stdlib only): receives the bot's outbound notify webhook POSTs ({text, from?} on MESH_NOTIFY_URL) and forwards each one as a message to an opencode API session (Basic auth). Runs with network_mode: host — it listens on the host's 0.0.0.0:4097, which is why MESH_NOTIFY_URL=http://172.24.0.1:4097/notify works from the bot container, and reaches opencode at 127.0.0.1:9898. restart: unless-stopped; no Traefik labels (host networking).
Config lives in .env (gitignored) and is passed via compose environment — credentials are never committed to the repo:
FORWARDER_BASE(defaulthttp://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance) — opencode instance base URLFORWARDER_SESSION(defaultses_f31037caeffeioRwixqGV7VS6m) — target session IDFORWARDER_USER/FORWARDER_PASS— Basic auth credentials; required, the container exits on startup if either is missing
Env vars
MESH_HOST(default192.168.1.4) — bridge hostMESH_PORT(default4403) — bridge portMESH_RECONNECT_MS(default5000) — reconnect interval in msMESH_SEND_CHANNEL(default3) — 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 channelMESH_NOTIFY_CHANNELfires a fire-and-forget HTTP POST with JSON body{text, from}(fromomitted 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(default3) — channel index that triggers the notify webhookMESH_DB_PATH(default./data/mesh-bot.db) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at/app/datavia a named volume so it survives container recreation)MESH_POLL_MS(default1000) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000PORT(default3000) — 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.