#!/usr/bin/env python3 """AudioPen webhook receiver. Listens for POST requests at /audiopen/ and writes the polished-prose payload to COUNCIL_INBOX_RAW as a bare markdown file. A separate cron/systemd job (or inotify) then runs audiopen-ingest.sh to normalise into the PKB inbox. Zero external dependencies (stdlib only). Designed to sit behind Tailscale Funnel or similar outbound-initiated tunnel — binds to 127.0.0.1 by default. Environment: AUDIOPEN_WEBHOOK_SECRET required. Shared secret in the URL path. AUDIOPEN_WEBHOOK_HOST default 127.0.0.1 AUDIOPEN_WEBHOOK_PORT default 8765 COUNCIL_INBOX_RAW default /var/mnt/data/projects/council/inbox/raw AudioPen's webhook payload shape varies by version; we accept any of these JSON keys for the body: `body`, `output`, `summary`, `polished`. For the title: `title`, `name`. Unknown fields are ignored. The raw payload is kept in an HTTP access log on stderr so you can inspect it if AudioPen changes shape. """ from __future__ import annotations import datetime as _dt import json import os import pathlib import re import sys from http.server import BaseHTTPRequestHandler, HTTPServer INBOX_RAW = pathlib.Path( os.environ.get("COUNCIL_INBOX_RAW", "/var/mnt/data/projects/council/inbox/raw") ) SECRET = os.environ.get("AUDIOPEN_WEBHOOK_SECRET") HOST = os.environ.get("AUDIOPEN_WEBHOOK_HOST", "127.0.0.1") PORT = int(os.environ.get("AUDIOPEN_WEBHOOK_PORT", "8765")) _SLUG_RE = re.compile(r"[^a-z0-9]+") def slugify(text: str, max_len: int = 60) -> str: s = _SLUG_RE.sub("-", text.strip().lower()).strip("-") return (s or "untitled")[:max_len] def extract_content(payload: dict) -> tuple[str, str]: title = str( payload.get("title") or payload.get("name") or "" ).strip() body = str( payload.get("body") or payload.get("output") or payload.get("summary") or payload.get("polished") or payload.get("orig_transcript") or "" ).strip() if not title and body: title = body.splitlines()[0][:60] return title or "Untitled", body class Handler(BaseHTTPRequestHandler): server_version = "audiopen-webhook/1" def _reply(self, status: int, msg: str = "") -> None: self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(msg))) self.end_headers() if msg: self.wfile.write(msg.encode()) def do_POST(self) -> None: parts = [p for p in self.path.split("/") if p] if len(parts) < 2 or parts[0] != "audiopen" or parts[1] != SECRET: self._reply(404, "not found") return length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length) if length > 0 else b"" try: payload = json.loads(raw) if raw else {} except json.JSONDecodeError: self._reply(400, "invalid JSON") return if not isinstance(payload, dict): self._reply(400, "expected JSON object") return title, body = extract_content(payload) if not body: self._reply(400, "empty body") return slug = slugify(title) ts = _dt.datetime.now().strftime("%Y-%m-%d-%H%M") filename = f"{ts}_{slug}.md" INBOX_RAW.mkdir(parents=True, exist_ok=True) target = INBOX_RAW / filename # Avoid overwriting on same-minute collision suffix = 2 while target.exists(): target = INBOX_RAW / f"{ts}_{slug}_{suffix}.md" suffix += 1 target.write_text(f"# {title}\n\n{body}\n", encoding="utf-8") sys.stderr.write(f"[audiopen-webhook] wrote {target}\n") self._reply(200, "ok\n") def do_GET(self) -> None: # Health check at /health; everything else returns 404. if self.path == "/health": self._reply(200, "ok\n") else: self._reply(404, "not found") def log_message(self, fmt: str, *args) -> None: sys.stderr.write(f"[audiopen-webhook] {self.address_string()} {fmt % args}\n") def main() -> int: if not SECRET: sys.stderr.write("AUDIOPEN_WEBHOOK_SECRET env var is required.\n") return 1 sys.stderr.write( f"[audiopen-webhook] listening on http://{HOST}:{PORT}, inbox_raw={INBOX_RAW}\n" ) HTTPServer((HOST, PORT), Handler).serve_forever() return 0 if __name__ == "__main__": sys.exit(main())