Files
council/scripts/audiopen-webhook/main.py
T
jpmschweitzerandClaude Opus 4.7 2c8df9425b add AudioPen ingest pipeline and sync architecture setup docs
Voice-note capture as the primary mobile input channel. AudioPen
polishes on their cloud, POSTs to a self-hosted receiver on the
desktop (exposed via Tailscale Funnel so no router ports open and
nothing behind Authentik). The receiver writes to inbox/raw/; a bash
normaliser wraps each drop in frontmatter and moves it to inbox/;
/triage-inbox routes them from there.

scripts/audiopen-ingest.sh — 80-line bash normaliser. Idempotent;
safe to re-run. Extracts title, slugifies, computes capture timestamp
from mtime, rewrites with fleeting-note frontmatter.

scripts/audiopen-webhook/main.py — stdlib-only Python HTTP server.
Zero pip deps; binds to 127.0.0.1 by default. Accepts flexible
payload shapes (title/name + body/output/summary/polished/
orig_transcript) so AudioPen version drift is logged rather than
silently dropped.

scripts/audiopen-webhook/*.example — systemd user-unit templates for
the receiver and the path/service pair that fires the ingest wrapper
on inbox/raw/ changes.

scripts/pyproject.toml + README.md — Python venv convention. Zero
deps today; venv location reserved at scripts/.venv/ (gitignored),
manifest at scripts/pyproject.toml, bootstrap documented for both
plain pip and uv. Optional-dependencies groups let individual tools
pull what they need without bloating the whole env.

docs/setup/audiopen.md — full setup guide: generating the shared
secret, installing the systemd units, pairing with Tailscale Funnel,
configuring AudioPen's webhook, and the IMAP-fallback path for
setups that can't run a public-reachable receiver.

docs/setup/sync.md — companion guide: Gitea SSH remote (works even
with Authentik gating HTTPS), obsidian-git plugin configuration,
Syncthing desktop↔phone pairing with the critical .stignore patterns,
and the three phone-role tiers so the user can pick Tier 1 / 2 / 3
at their own pace.

.gitignore gains **/.venv/, **/venv/, **/__pycache__/, and *.pyc so
nobody accidentally commits a materialised environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:17:48 +02:00

140 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""AudioPen webhook receiver.
Listens for POST requests at /audiopen/<shared-secret> 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())