diff --git a/CHANGELOG.md b/CHANGELOG.md index f0bb669..f231a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 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). diff --git a/README.md b/README.md index c8de671..760c706 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,16 @@ 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_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` (default `http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance`) — opencode instance base URL +- `FORWARDER_SESSION` (default `ses_f31037caeffeioRwixqGV7VS6m`) — target session ID +- `FORWARDER_USER` / `FORWARDER_PASS` — Basic auth credentials; **required**, the container exits on startup if either is missing + ## Env vars - `MESH_HOST` (default `192.168.1.4`) — bridge host diff --git a/docker-compose.yml b/docker-compose.yml index 9882176..4c86080 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,15 @@ services: - 'traefik.http.routers.mesh-bot.rule=Host(`bot.dev.kkhost.pl`)' - 'traefik.http.routers.mesh-bot.entrypoints=web' - 'traefik.http.services.mesh-bot.loadbalancer.server.port=3000' + forwarder: + build: ./ops/forwarder + network_mode: host + environment: + FORWARDER_BASE: ${FORWARDER_BASE:-http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance} + FORWARDER_SESSION: ${FORWARDER_SESSION:-ses_f31037caeffeioRwixqGV7VS6m} + FORWARDER_USER: ${FORWARDER_USER:-} + FORWARDER_PASS: ${FORWARDER_PASS:-} + restart: unless-stopped volumes: mesh_data: diff --git a/ops/forwarder/Dockerfile b/ops/forwarder/Dockerfile new file mode 100644 index 0000000..995d690 --- /dev/null +++ b/ops/forwarder/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.12-slim +WORKDIR /app +COPY forwarder.py . +CMD ["python3", "forwarder.py"] diff --git a/ops/forwarder/forwarder.py b/ops/forwarder/forwarder.py new file mode 100644 index 0000000..69461f2 --- /dev/null +++ b/ops/forwarder/forwarder.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth). + +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 + +BASE = os.environ.get("FORWARDER_BASE", "http://127.0.0.1:9898/workspaces/679fd6f6-b237-4a46-bf69-64cb0fc865b0/instance") +SESSION = os.environ.get("FORWARDER_SESSION", "ses_f31037caeffeioRwixqGV7VS6m") +USER = os.environ["FORWARDER_USER"] # required; fail fast if missing +PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing + +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.' + req = urllib.request.Request( + f"{BASE}/session/{SESSION}/message", + data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(), + headers={"Content-Type": "application/json", + "Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()}, + 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 {SESSION}: 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 {SESSION}: accepted (response still pending after 15 s)", flush=True) + self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}') + 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() diff --git a/package.json b/package.json index 5cc1dc9..a6629b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.12.1", + "version": "0.12.2", "description": "", "author": "", "private": true,