CI / build + unit (pull_request) Successful in 46s
CodeNomad's per-workspace instance proxy was upgraded and now only
serves opencode v2 /api/* routes; the v1 /session/... routes 403, which
broke all notifications.
- Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope)
instead of GET {base}/session; sessions filtered by location.directory
and the max time.updated entry is picked.
- Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text}
body instead of POST {base}/session/{id}/message with {parts:[...]}.
- v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered
branch is kept only as a harmless safety net.
- Updated the spec to the v2 routes/body/envelope (incl. a
directory-filter assertion) and kept AGENTS.md in sync.
16 KiB
16 KiB
MeshBot — Workflow Rules
Change process (mandatory)
- 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).
- 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 tomain. - Changelog entry — every such change is listed in
CHANGELOG.mdwith date, time and version (format:## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed). - Version bump — the version is updated in
package.json. - Verification must pass — there is no CI pipeline yet; before opening a PR both local checks must be green:
npm run buildandnpm test(vitest unit suite). - 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 --buildon the host.
Verification
- Local checks —
npm run build(nest build) andnpm test(vitest; no e2e suite yet). Both must pass before a PR is opened. - Docker —
docker compose build, then afterdocker compose up -d:curl -s -H "Host: bot.dev.kkhost.pl" http://localhost/api/statusreturns JSON ({ connected, myNodeNum, host, port })."connected":falseis normal while the bridge/device is down — the service retries every 5 s. An idle watchdog also force-reconnects an ESTABLISHED-but-silent connection (zombie bridge: outbound works, inbound dead) when no DECODED FRAME arrives from the bridge forMESH_IDLE_TIMEOUT_MSms (default 60000 = 1 min; checked every 30 s — raw non-frame bytes do NOT count), an undecoded frame buffer overMESH_MAX_FRAME_BUFFERbytes (default 256000) forces a reconnect as a stream-desync backstop, and the socket has TCP keepalive enabled (30 s). A lazy periodic autoreconnect additionally unconditionally re-establishes everyMESH_RECONNECT_INTERVAL_MSms (default 300000 = 5 min) for a fresh sync.
API
- 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). - Endpoints — all under the
/apiglobal 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 likeGET /api/nodes/:id, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. OptionalreplyId(integer in 1..0xffffffff, else 400) 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 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 carriesreplyId = messageIdso 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 withfromandto, tx sends withto+statuspending→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 inGET /api/messageslike 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,fromomitted 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) andGET /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. AllPOST /api/sendtraffic targets channel index 3 (test) on purpose — never the primary channel; override viaMESH_SEND_CHANNEL(default3). Message history is persisted to SQLite: file path fromMESH_DB_PATH(default./data/mesh-bot.db, parent dir auto-created), poller re-upserts everyMESH_POLL_MSms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in theMESH_NOTIFY_CHANNELcomma-separated list (default3; entries trimmed, empty/invalid ones ignored), the in-processOpencodeServicenotifier (src/opencode, registered inMeshModule) 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, defaulthttp://172.24.0.1:9898; matched byOPENCODE_WORKSPACE_PATH(default/home/krzyw/projects/sandbox/mesh-bot), fallback namemesh-bot, statusreadypreferred) and — when no session is pinned — discovers the most recently updated one viaGET {api}{proxyPath}/api/session(opencode v2: the response is a{ data, cursor }envelope of session entries whose directory lives underlocation.directoryand whosetime.updatedis epoch ms; only entries withlocation.directory= the workspace path are considered, then maxtime.updatedwins) — or the pinnedOPENCODE_SESSIONwhen set, which skips discovery. The old v1/sessiondiscovery route is gone: CodeNomad's per-workspace instance proxy now only serves v2/api/*(v1/session/…403s). The message is posted toPOST {api}{proxyPath}/api/session/{sid}/promptand the body is exactly{"text":msg}with msg =New mesh message (id <packetId>)[ from node <n>]: "<text>" — please react to it.(thefrom nodepart omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth viaOPENCODE_USER/OPENCODE_PASS; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; v2 returns 2xx immediately (a 2xx = delivered), and a read timeout on the final prompt POST is still treated as delivered success without retry (kept as a harmless safety net), 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 when0is 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). - MCP endpoint —
POST /mcp(excluded from the/apiprefix in main.ts, like Scalar at/): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (sessionIdGenerator: undefined, freshMcpServer+ transport per request). Tools map 1:1 to MeshService methods:mesh_status,send_message(text, to?, replyId?)(toresolves likePOST /api/send; optionalreplyIdinteger in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id fromGET /api/messagesorget_messages; errors surface as tool errors with the same messages),respond(messageId, content)(reply to an earlier message by packet id fromget_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 asPOST /api/messages),get_telemetry(same bounded recent window asGET /api/telemetry),device_info(same redaction asGET /api/device). Results are JSON in a text content block; sends ride channel index 3 like everything else (respondrides the record's channel). Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (@modelcontextprotocol/sdk/server/mcp.jsetc.).
Other
- For longer terminal tasks you should use pty_* tools.
- Host npm (10.9.4) has an arborist crash bug ("edgesOut") — if plain
npmmisbehaves on installs, usenpx npm@latest .... The lockfile must stay in sync with package.json: the Docker build runsnpm 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; envWEATHER_CRON/WEATHER_LAT/WEATHER_LON/WEATHER_CITY. - Channel-2 auto-responder (
src/commands): event-driven —CommandService.onRx()is called byMeshServicefor every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive#commandmatches onCOMMAND_CHANNEL(env, default 2) through a single-registration-point registry (handlers registered in the constructor viaregister(name, fn); handler gets{ text, from?, id? }, returns a reply string — or an array of lines, sent as one message per line through the same routing, spacedMESH_MULTI_SEND_DELAY_MSms apart (default 5000) so each packet has time to land in RF — or void = no reply). First commands:#ping→ replies#pong!;#loser→ worst packet delivery record over directed sends only (groups our own tx history by destination, counts delivered vs failed per node; broadcasts excluded — firmware strips want_ack so they stay pending and the broadcast addr is never a real destination; loser = most failures, tie-break worse failure ratio; destination resolved to long/short name with@!xxxxxxxxmention fallback when unknown; three reply shapes:worst delivery: <name> (@!hex) - N failed / M delivered, "no failed sends yet", or "not enough data yet" explaining only directed sends count);#help→ short single-message list of all available commands. Replies go throughMeshService.respond(id, reply): auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel withreplyId = idso the recipient app renders it as a threaded quote —COMMAND_CHANNELis only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing onCOMMAND_CHANNEL. A failing handler/respond only logs a warn. DI: provided byCommandModule's factory (injectsMeshService, attaches itself viaattachCommands()) — no constructor-level cycle;forwardRefwas tried and left one side null in NestJS 12's injector. Prefix dispatch (registerPrefix(name, fn)) lets one handler own a whole family: the#rsssubcommands live insrc/rss(RssService.handle, registered as prefix'rss'from the CommandModule factory — exact matches still win over prefixes). Subcommands:#rss help;#rss add <url> <name>(validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so);#rss remove <name>;#rss tags(all distinct tags);#rss sources;#rss get <name|#tag>(LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line,<short header>: <one or two short sentences>with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstopfitOneLine()hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment;#tagcollects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage:rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing viafast-xml-parser(RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injectedLlmService.chat()with explicit maxTokens (50 tags / 2500 summary headroom). - LLM client (
src/llm):LlmServicewraps the local OpenAI-compatible server (defaulthttp://192.168.1.70:1111/v1, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods:listModels()→data[].id;chat(messages, { maxTokens?, temperature? })→choices[0].message.content(non-2xx throws with status + body snippet);summarize(text)= chat() with system prompt "Summarize the following text concisely." Env:LLM_API_BASE,LLM_MODEL(defaultqwen3.8-27b),LLM_TIMEOUT_MS(default 180000, viaAbortSignal.timeout).