Files
MeshBot/AGENTS.md
T
kamilkrzywda 931b42bb1e
CI / build + unit (pull_request) Successful in 41s
Add channel-2 #command auto-responder (v0.18.0)
Event-driven CommandService dispatches exact case-sensitive #command
matches on COMMAND_CHANNEL (default 2) through a single-registration-point
registry; first command #ping -> #pong!. Replies ride COMMAND_CHANNEL:
directed DM back to the sender when present, else broadcast. Failing
handler/send only logs a warn.

DI: CommandModule's factory injects MeshService and attaches itself via
MeshService.attachCommands() — a constructor-level cycle leaves one side
null in NestJS 12's injector (verified), so no forwardRef is used.

Tests 106 -> 121; docs synced (AGENTS.md, MESHTASTIC.md, README).
2026-09-25 17:11:52 +00:00

11 KiB

MeshBot — Workflow Rules

Change process (mandatory)

  1. Small, incremental changes — work is done one change at a time, in smaller parts; each part must be tested thoroughly with unit tests covering it before moving on. Connected issues/tasks that reference the same part of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same codec function).
  2. Feature branch + PR — every change lands on a feature branch off main (no direct pushes to main). The branch carries the commits below; open a pull request to main.
  3. Changelog entry — every such change is listed in CHANGELOG.md with date, time and version (format: ## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed).
  4. Version bump — the version is updated in package.json.
  5. Verification must pass — there is no CI pipeline yet; before opening a PR both local checks must be green: npm run build and npm test (vitest unit suite).
  6. Tag + manual deploy after merge — once merged, tag the version on main (e.g. v0.1.0). The service (http://bot.dev.kkhost.pl) is updated manually afterwards: docker compose up -d --build on the host.

Verification

  1. Local checks — npm run build (nest build) and npm test (vitest; no e2e suite yet). Both must pass before a PR is opened.
  2. Docker — docker compose build, then after docker compose up -d: curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/status returns JSON ({ connected, myNodeNum, host, port }). "connected":false is normal while the bridge/device is down — the service retries every 5 s.

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"?: <node number | node name | "!b" | "broadcast">, "replyId"?: <packet id> } — 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": <packet id>, "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: {<section>: ...}, moduleConfig: {<module>: ...} } — 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"?: <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, nothing goes to the bridge or air; appears in GET /api/messages like any other rx message), GET /api/forwarded (the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as { id, from?, text, channel, at }[]: channel in the MESH_NOTIFY_CHANNEL list (ch0: directed DMs only), not a self-echo, notifier wired; id is the packet id for real rx or the generated record id for injected, from omitted when there is no sender; in-memory only, reset on restart), 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 a channel in the MESH_NOTIFY_CHANNEL comma-separated list (default 3; entries trimmed, empty/invalid ones ignored), the in-process OpencodeService notifier (src/opencode, registered in MeshModule) pushes it to opencode — fire-and-forget, no response handling. Channel 0 is the shared default channel where other users talk: only directed DMs (both sender and recipient present) notify there, ch0 broadcasts are ignored. 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 <packetId>)[ from node <n>]: "<text>" — 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 0 is in the list — injections ride ch0 by design and are exempt from the ch0 DM-only rule. 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

  1. For longer terminal tasks you should use pty_* tools.
  2. Host npm (10.9.4) has an arborist crash bug ("edgesOut") — if plain npm misbehaves on installs, use npx npm@latest .... The lockfile must stay in sync with package.json: the Docker build runs npm ci.
  • MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
  • Hourly cron weather report job (src/weather): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env WEATHER_CRON/WEATHER_LAT/WEATHER_LON/WEATHER_CITY.
  • Channel-2 auto-responder (src/commands): event-driven — CommandService.onRx() is called by MeshService for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive #command matches on COMMAND_CHANNEL (env, default 2) through a single-registration-point registry (handlers registered in the constructor via register(name, fn); handler gets { text, from?, id? }, returns reply string or void = no reply). First command: #ping → replies #pong!. Replies ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast. A failing handler/send only logs a warn. DI: provided by CommandModule's factory (injects MeshService, attaches itself via attachCommands()) — no constructor-level cycle; forwardRef was tried and left one side null in NestJS 12's injector.