Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca0dfa894 | ||
|
|
5e03546f40 |
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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).
|
||||||
|
- `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,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_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 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
|
## Env vars
|
||||||
|
|
||||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||||
|
|||||||
@@ -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,15 @@ 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_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:
|
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,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()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.12.0",
|
"version": "0.12.2",
|
||||||
"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 {}
|
||||||
|
|||||||
@@ -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