10 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">, "replyId"?: <packet id> }, 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 400. OptionalreplyId(integer in 1..0xffffffff) references an earlier message's packet id fromGET /api/messages— when set, the recipient app renders the text as a threaded reply/quote of that packetGET /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?, replyId?)— send a text message;toresolves likePOST /api/send(omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); optionalreplyIdmakes the message render as a threaded reply/quote of that packet id fromget_messages; 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
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 <n>]: "<text>" — 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(defaulthttp://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 itspathfield (fallback namemesh-bot; statusreadypreferred 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 requestOPENCODE_USER/OPENCODE_PASS— Basic auth credentials; missing/empty disables the notifier (one startup log line, no-op) instead of failing
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_CHANNEL(default3) — 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/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.