Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5999159e17 | ||
|
|
36021ec2e8 | ||
|
|
dca0dfa894 | ||
|
|
5e03546f40 |
@@ -17,7 +17,7 @@
|
|||||||
## API
|
## 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).
|
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"> }` — 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. Returns `{ id }`), `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`, 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/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 webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; no retries, no response handling. 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).
|
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"> }` — 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. Returns `{ id }`), `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`, 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/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 webhook: when an RX text arrives on channel `MESH_NOTIFY_CHANNEL` (default `3`), a fire-and-forget HTTP POST of `{text, from}` (`from` omitted when absent) goes to `MESH_NOTIFY_URL` — unset/empty disables it entirely; failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped; no response handling. 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). The forwarder resolves the mesh-bot workspace/session dynamically from the opencode API per request (`FORWARDER_API`/`FORWARDER_WORKSPACE_PATH`; `FORWARDER_SESSION` pins a session when set).
|
||||||
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?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `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. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
|
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?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `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. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.12.4 — 2026-09-25 10:17
|
||||||
|
|
||||||
|
- Notify forwarder (`ops/forwarder`) now resolves the opencode workspace + session dynamically per request instead of hardcoding them as env defaults: `GET {FORWARDER_API}/api/workspaces` → match by `path == FORWARDER_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. Fixes lost user messages after an opencode host restart regenerated the workspace UUID (the old hardcoded base URL 404'd on every notify POST).
|
||||||
|
- New env config replaces `FORWARDER_BASE`: `FORWARDER_API` (default `http://127.0.0.1:9898`) and `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`); `FORWARDER_SESSION` is now an optional pin — when set, session discovery is skipped; empty/unset discovers dynamically. `FORWARDER_USER` / `FORWARDER_PASS` stay required. Each failure mode gets a clear 502 + log line: API unreachable, workspace not found, no sessions in workspace. The message POST itself (endpoint under `{proxyPath}/session/{sid}`, body and auth), timeout=15 semantics, 202-on-timeout and the `_reply` BrokenPipe guard are unchanged.
|
||||||
|
- `docker-compose.yml`: forwarder environment block now passes `FORWARDER_API` / `FORWARDER_WORKSPACE_PATH` / `FORWARDER_SESSION` (empty default) — old `FORWARDER_BASE` line removed; bot service untouched.
|
||||||
|
- `MeshService.postNotify()`: after `await fetch(...)`, checks `res.ok` and throws `Error('HTTP <status>')` when not ok, so a forwarder 5xx during an opencode restart is retried with the same backoff as network failures (the `res &&` guard keeps specs that stub fetch to resolve `undefined` green). One new spec: fetch resolves status 502 twice then 200 → exactly 3 attempts, success path. All 61 pre-existing tests stay green (62 total).
|
||||||
|
- Docs: README "Notify forwarder" section rewritten for dynamic resolution + the new env vars; AGENTS.md webhook note gains a clause that the forwarder resolves the mesh-bot workspace/session from the opencode API per request (`FORWARDER_SESSION` pins a session when set).
|
||||||
|
|
||||||
|
## v0.12.3 — 2026-09-25 09:40
|
||||||
|
|
||||||
|
- Notify webhook POSTs are now retried instead of dropped on the first failure: `notifyRx`'s fire-and-forget POST runs through a retry loop with up to 4 attempts total (initial + 3 retries) and backoff delays `[2 s, 10 s, 30 s]` (~45 s window). Each failed attempt logs WARN with the attempt number and error; after the final failure it logs WARN `notify dropped after N attempts: ...` and stops. Success on any attempt ends the loop (unchanged success behavior/logging). Fire-and-forget semantics preserved — the RX handler never awaits the loop, which runs as an un-awaited async IIFE exactly like today's single POST. Self-echo filter and channel-match logic unchanged; backoff is hardcoded (no new env vars).
|
||||||
|
- New private `MeshService.postNotify()` retry helper + injectable `sleep()` so specs can skip the delays without fake timers. Two new specs: fetch fails twice then succeeds → exactly 3 attempts, no dropped log, success path taken; fetch always fails → exactly 4 attempts, final WARN `notify dropped after 4 attempts`, nothing thrown. All 59 pre-existing tests stay green (61 total).
|
||||||
|
- Docs: README `MESH_NOTIFY_URL` bullet and AGENTS.md webhook note now state failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped.
|
||||||
|
|
||||||
|
## v0.12.2 — 2026-09-25 08:44
|
||||||
|
|
||||||
|
- New compose service `forwarder` (`ops/forwarder`, `python:3.12-slim`, stdlib only, no pip installs): runs the notify forwarder that was previously a host-side `nohup` python script — 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).
|
||||||
|
- `network_mode: host` → listens on the host's `0.0.0.0:4097`, so `MESH_NOTIFY_URL=http://172.24.0.1:4097/notify` keeps working unchanged from the bot container, and it reaches opencode at `127.0.0.1:9898`. No `networks:` key (host mode), no Traefik labels; `restart: unless-stopped`.
|
||||||
|
- Config moves out of the script into compose environment from `.env` (gitignored): `FORWARDER_BASE` / `FORWARDER_SESSION` keep their current values as defaults, `FORWARDER_USER` / `FORWARDER_PASS` are required — the container exits on startup if either is missing. The real credentials are no longer hardcoded anywhere in the repo; nothing sensitive committed.
|
||||||
|
- No app code changes — existing 59 tests stay green.
|
||||||
|
|
||||||
|
## v0.12.1 — 2026-09-25 07:57
|
||||||
|
|
||||||
|
- Hotfix for the v0.12.0 bootstrap crash (`UnknownDependenciesException`): `MeshModule` now exports `MeshService`, and `WeatherService`'s `import type { MeshService }` became a value import — with `emitDecoratorMetadata` the type-only import is erased, so the constructor's DI token was `undefined` at runtime even with the export (the unit suite instantiates services directly and never booted the module graph, which is why 58 green tests still shipped a broken app).
|
||||||
|
- `docker-compose.yml`: `WEATHER_CRON` passthrough (`${WEATHER_CRON:-}`) mirroring the existing notify-var pattern — empty/unset falls back to the service default `'0 * * * *'`. No LAT/LON/CITY passthroughs (service-side defaults suffice).
|
||||||
|
- New DI smoke spec `src/weather/di.spec.ts`: compiles `WeatherModule` with Nest's `Test.createTestingModule().compile()` (no `app.init()`, so no TCP connection) and asserts `WeatherService` resolves its `MeshService` from `MeshModule` — fails if the export regresses.
|
||||||
|
|
||||||
## v0.12.0 — 2026-09-25 07:42
|
## v0.12.0 — 2026-09-25 07:42
|
||||||
|
|
||||||
- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code <n>`). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app.
|
- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code <n>`). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app.
|
||||||
|
|||||||
@@ -97,13 +97,24 @@ Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Ope
|
|||||||
- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo
|
- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo
|
||||||
- `WEATHER_CITY` (default `"Lublin"`) — city name in the report line
|
- `WEATHER_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 the mesh-bot folder's live opencode session (Basic auth). The opencode workspace UUID is regenerated on host restarts, so the forwarder resolves it dynamically per request via the opencode API: `GET /api/workspaces` → match by `path == FORWARDER_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. 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_API` (default `http://127.0.0.1:9898`) — opencode API base URL
|
||||||
|
- `FORWARDER_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`) — workspace matched by its `path` field (fallback name `mesh-bot`; status `ready` preferred when several match)
|
||||||
|
- `FORWARDER_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 request
|
||||||
|
- `FORWARDER_USER` / `FORWARDER_PASS` — Basic auth credentials; **required**, the container exits on startup if either is missing
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
|
|
||||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||||
- `MESH_PORT` (default `4403`) — bridge port
|
- `MESH_PORT` (default `4403`) — bridge port
|
||||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
- `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_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_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). Failed POSTs are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. 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_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_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
|
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
MESH_POLL_MS: '1000'
|
MESH_POLL_MS: '1000'
|
||||||
MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-}
|
MESH_NOTIFY_URL: ${MESH_NOTIFY_URL:-}
|
||||||
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
MESH_NOTIFY_CHANNEL: '${MESH_NOTIFY_CHANNEL:-3}'
|
||||||
|
WEATHER_CRON: ${WEATHER_CRON:-}
|
||||||
volumes:
|
volumes:
|
||||||
- mesh_data:/app/data
|
- mesh_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -18,6 +19,16 @@ services:
|
|||||||
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
- 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)'
|
||||||
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
- 'traefik.http.routers.mesh-bot.entrypoints=web'
|
||||||
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
- 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000'
|
||||||
|
forwarder:
|
||||||
|
build: ./ops/forwarder
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
FORWARDER_API: ${FORWARDER_API:-http://127.0.0.1:9898}
|
||||||
|
FORWARDER_WORKSPACE_PATH: ${FORWARDER_WORKSPACE_PATH:-/home/krzyw/projects/sandbox/mesh-bot}
|
||||||
|
FORWARDER_SESSION: ${FORWARDER_SESSION:-}
|
||||||
|
FORWARDER_USER: ${FORWARDER_USER:-}
|
||||||
|
FORWARDER_PASS: ${FORWARDER_PASS:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mesh_data:
|
mesh_data:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY forwarder.py .
|
||||||
|
CMD ["python3", "forwarder.py"]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth).
|
||||||
|
|
||||||
|
The opencode workspace UUID is regenerated on host restarts, so the target workspace + session are
|
||||||
|
resolved dynamically per request via the opencode API (no caching — messages are rare): list
|
||||||
|
workspaces, pick the mesh-bot one by path (fallback: name), then its most recently updated session.
|
||||||
|
|
||||||
|
Note on semantics: the opencode API accepts a session message immediately but holds the HTTP
|
||||||
|
response until the agent turn completes — so a read timeout here means DELIVERED, not failed.
|
||||||
|
"""
|
||||||
|
import json, base64, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import os
|
||||||
|
|
||||||
|
API = os.environ.get("FORWARDER_API", "http://127.0.0.1:9898")
|
||||||
|
WORKSPACE_PATH = os.environ.get("FORWARDER_WORKSPACE_PATH", "/home/krzyw/projects/sandbox/mesh-bot")
|
||||||
|
SESSION_OVERRIDE = os.environ.get("FORWARDER_SESSION", "") # when set, skips session discovery
|
||||||
|
USER = os.environ["FORWARDER_USER"] # required; fail fast if missing
|
||||||
|
PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing
|
||||||
|
|
||||||
|
def _auth_headers():
|
||||||
|
return {"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()}
|
||||||
|
|
||||||
|
def _get_json(url):
|
||||||
|
req = urllib.request.Request(url, headers=_auth_headers())
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"opencode API unreachable at {url}: {e!r}") from None
|
||||||
|
|
||||||
|
def resolve_target():
|
||||||
|
"""Resolve (base_url, session_id) fresh per request; raises RuntimeError with a clear reason."""
|
||||||
|
workspaces = _get_json(API + "/api/workspaces")
|
||||||
|
matches = [w for w in workspaces if w.get("path") == WORKSPACE_PATH] or \
|
||||||
|
[w for w in workspaces if w.get("name") == "mesh-bot"]
|
||||||
|
pool = [w for w in matches if w.get("status") == "ready"] or matches
|
||||||
|
if not pool:
|
||||||
|
raise RuntimeError(f"workspace not found: path={WORKSPACE_PATH} name=mesh-bot")
|
||||||
|
base = API + pool[0]["proxyPath"]
|
||||||
|
sid = SESSION_OVERRIDE
|
||||||
|
if not sid:
|
||||||
|
sessions = _get_json(base + "/session")
|
||||||
|
if not sessions:
|
||||||
|
raise RuntimeError(f"no sessions in workspace {pool[0]['id']} ({WORKSPACE_PATH})")
|
||||||
|
|
||||||
|
def updated(s):
|
||||||
|
return (s.get("time") or {}).get("updated") or 0
|
||||||
|
sid = max(sessions, key=updated)["id"] if any(updated(s) for s in sessions) else sessions[0]["id"]
|
||||||
|
return base, sid
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/notify":
|
||||||
|
self.send_response(404); self.end_headers(); return
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
text, frm = body.get("text"), body.get("from")
|
||||||
|
msg = 'New mesh message' + (f' from node {frm}' if frm is not None else '') + f': "{text}" — please react to it.'
|
||||||
|
base, sid = resolve_target() # fresh per request; RuntimeError on API/workspace/session failure
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base}/session/{sid}/message",
|
||||||
|
data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(),
|
||||||
|
headers={"Content-Type": "application/json", **_auth_headers()},
|
||||||
|
method="POST")
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=15)
|
||||||
|
code = r.status # headers received => opencode accepted the message
|
||||||
|
try: out = r.read(200).decode(errors='replace')
|
||||||
|
except Exception as e2: out = f'(body read failed after delivery: {e2!r})'
|
||||||
|
print(f"forwarded to opencode session {sid}: HTTP {code} {out[:150]!r}", flush=True)
|
||||||
|
self._reply(code if 200 <= code < 300 else 502, b'{"ok":true}' if 200 <= code < 300 else str(code).encode())
|
||||||
|
except TimeoutError:
|
||||||
|
# request was sent; opencode holds the response until the agent turn finishes => delivered
|
||||||
|
print(f"forwarded to opencode session {sid}: accepted (response still pending after 15 s)", flush=True)
|
||||||
|
self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}')
|
||||||
|
except RuntimeError as e:
|
||||||
|
# resolve_target() failure modes: API unreachable / workspace not found / no sessions in workspace
|
||||||
|
print(f"target resolution failed: {e!r}", flush=True)
|
||||||
|
self._reply(502, repr(e).encode())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"opencode call failed: {e!r}", flush=True)
|
||||||
|
self._reply(502, repr(e).encode())
|
||||||
|
|
||||||
|
def _reply(self, code, payload):
|
||||||
|
try:
|
||||||
|
self.send_response(code); self.end_headers(); self.wfile.write(payload)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass # client went away; delivery state already logged above
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print("forwarder:", fmt % args, flush=True)
|
||||||
|
|
||||||
|
HTTPServer(("0.0.0.0", 4097), H).serve_forever()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.12.0",
|
"version": "0.12.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ function openMessageDb(): DatabaseSync {
|
|||||||
useFactory: () => new MeshService(openMessageDb()),
|
useFactory: () => new MeshService(openMessageDb()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
exports: [MeshService],
|
||||||
})
|
})
|
||||||
export class MeshModule {}
|
export class MeshModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import * as P from '@meshtastic/protobufs';
|
import * as P from '@meshtastic/protobufs';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -789,6 +790,100 @@ describe('MeshService notify webhook', () => {
|
|||||||
bridge.close();
|
bridge.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries a failed webhook POST with backoff and stops on success', async () => {
|
||||||
|
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('boom 1'))
|
||||||
|
.mockRejectedValueOnce(new Error('boom 2'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||||
|
const sleep = vi.fn(async (_ms: number) => {});
|
||||||
|
const svc = new MeshService();
|
||||||
|
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
||||||
|
try {
|
||||||
|
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
|
||||||
|
|
||||||
|
await waitFor(() => fetchMock.mock.calls.length === 3);
|
||||||
|
// success on the third attempt: exactly 3 POSTs, no dropped log
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
||||||
|
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
|
||||||
|
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
|
||||||
|
expect(warns.some((w) => w.includes('attempt 2/4'))).toBe(true);
|
||||||
|
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.MESH_NOTIFY_URL;
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a webhook POST that answers with an HTTP error status and stops on success', async () => {
|
||||||
|
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 502 })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 502 })
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||||
|
const sleep = vi.fn(async (_ms: number) => {});
|
||||||
|
const svc = new MeshService();
|
||||||
|
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
||||||
|
try {
|
||||||
|
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
|
||||||
|
|
||||||
|
await waitFor(() => fetchMock.mock.calls.length === 3);
|
||||||
|
// HTTP 502 twice, then 200: exactly 3 POSTs, no dropped log
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
|
||||||
|
expect(warns.some((w) => w.includes('HTTP 502'))).toBe(true);
|
||||||
|
expect(warns.some((w) => w.includes('notify dropped'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.MESH_NOTIFY_URL;
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up after 4 attempts and logs notify dropped', async () => {
|
||||||
|
process.env.MESH_NOTIFY_URL = 'http://notify.example/hook';
|
||||||
|
process.env.MESH_NOTIFY_CHANNEL = '0';
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new Error('down'));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
||||||
|
const sleep = vi.fn(async (_ms: number) => {});
|
||||||
|
const svc = new MeshService();
|
||||||
|
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
||||||
|
try {
|
||||||
|
expect(() => svc.injectMessage('hi', 42)).not.toThrow();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
warnSpy.mock.calls.some((c) =>
|
||||||
|
String(c[0]).includes('notify dropped after 4 attempts'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// exactly 4 attempts, final WARN with the exact message, nothing thrown
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
|
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(warns).toContain('notify dropped after 4 attempts: down');
|
||||||
|
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.MESH_NOTIFY_URL;
|
||||||
|
delete process.env.MESH_NOTIFY_CHANNEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MeshService injectMessage', () => {
|
describe('MeshService injectMessage', () => {
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ function jsonSafe<T>(value: T): T {
|
|||||||
|
|
||||||
// key material must never leave the process — replaced with a marker so callers can see the field exists
|
// key material must never leave the process — replaced with a marker so callers can see the field exists
|
||||||
const REDACTED = 'REDACTED';
|
const REDACTED = 'REDACTED';
|
||||||
|
// ponytail: hardcoded notify-webhook backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
|
||||||
|
const NOTIFY_RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
|
||||||
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
|
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
|
||||||
function redactSection(
|
function redactSection(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -441,14 +443,39 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const body: Record<string, unknown> = { text };
|
const body: Record<string, unknown> = { text };
|
||||||
if (from !== undefined) body.from = from;
|
if (from !== undefined) body.from = from;
|
||||||
void fetch(this.notifyUrl, {
|
void this.postNotify(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST the webhook with retries: up to NOTIFY_RETRY_DELAYS_MS.length + 1 attempts total. */
|
||||||
|
private async postNotify(body: Record<string, unknown>): Promise<void> {
|
||||||
|
const maxAttempts = NOTIFY_RETRY_DELAYS_MS.length + 1;
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.notifyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).catch((err: unknown) =>
|
});
|
||||||
this.logger.warn('notify webhook failed: ' + (err as Error).message),
|
if (res && !res.ok) throw new Error('HTTP ' + res.status); // HTTP errors retry like network failures
|
||||||
|
return; // success on any attempt — same as today's single POST
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
this.logger.warn(
|
||||||
|
`notify webhook failed (attempt ${attempt}/${maxAttempts}): ${msg}`,
|
||||||
);
|
);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
this.logger.warn(`notify dropped after ${attempt} attempts: ${msg}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await this.sleep(NOTIFY_RETRY_DELAYS_MS[attempt - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable so specs can skip the backoff delays. */
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MeshService } from '../mesh/mesh.service.js';
|
||||||
|
import { WeatherModule } from './weather.module.js';
|
||||||
|
import { WeatherService } from './weather.service.js';
|
||||||
|
|
||||||
|
describe('DI wiring (module graph)', () => {
|
||||||
|
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
|
||||||
|
it('resolves WeatherService with MeshService provided by MeshModule', async () => {
|
||||||
|
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [WeatherModule],
|
||||||
|
}).compile();
|
||||||
|
expect(moduleRef.get(WeatherService)).toBeInstanceOf(WeatherService);
|
||||||
|
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||||
import type { MeshService } from '../mesh/mesh.service.js';
|
import { MeshService } from '../mesh/mesh.service.js';
|
||||||
|
|
||||||
/** Open-Meteo `current` block fields we use (free API, no key). */
|
/** Open-Meteo `current` block fields we use (free API, no key). */
|
||||||
export interface CurrentWeather {
|
export interface CurrentWeather {
|
||||||
|
|||||||
Reference in New Issue
Block a user