Input
{
"patchText": "*** Begin Patch\n*** Add File: /opt/rickroll-sip/richard_app.py\n+#!/usr/bin/env python3\n+import html\n+import json\n+import os\n+import re\n+import shutil\n+import signal\n+import socket\n+import subprocess\n+import threading\n+import time\n+from datetime import datetime, timezone\n+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\n+from pathlib import Path\n+\n+\n+ROOT = Path(__file__).resolve().parent\n+CONFIG_DIR = ROOT / \"baresip-runtime\"\n+WAV_PATH = ROOT / \"rickroll.wav\"\n+HOST = \"127.0.0.1\"\n+PORT = int(os.environ.get(\"RICHARD_INFO_PORT\", \"8095\"))\n+LOG_LIMIT = 500\n+\n+log_lock = threading.Lock()\n+log_lines: list[str] = []\n+baresip_process: subprocess.Popen | None = None\n+stopping = threading.Event()\n+\n+SECRET_PATTERNS = [\n+ re.compile(r\"(auth_pass=[REDACTED];\\s]+\", re.I),\n+ re.compile(r\"(SIP_PASSWORD=[REDACTED]", re.I),\n+]\n+\n+\n+def redact(text: str) -> str:\n+ for pattern in SECRET_PATTERNS:\n+ text = pattern.sub(r\"\\1[redacted]\", text)\n+ return text\n+\n+\n+def append_log(line: str) -> None:\n+ line = redact(line.rstrip())\n+ print(line, flush=True)\n+ with log_lock:\n+ log_lines.append(f\"{datetime.now(timezone.utc).isoformat()} {line}\")\n+ del log_lines[:-LOG_LIMIT]\n+\n+\n+def load_env(path: Path) -> dict[str, str]:\n+ env = dict(os.environ)\n+ if path.exists():\n+ for line in path.read_text().splitlines():\n+ line = line.strip()\n+ if not line or line.startswith(\"#\") or \"=\" not in line:\n+ continue\n+ key, value = line.split(\"=\", 1)\n+ env.setdefault(key, value)\n+ return env\n+\n+\n+def require(env: dict[str, str], key: str) -> str:\n+ value = env.get(key)\n+ if not value or value.startswith(\"fill-in-\"):\n+ raise SystemExit(f\"Missing required setting: {key}\")\n+ return value\n+\n+\n+def run(command: list[str], timeout: int = 5) -> tuple[int, str]:\n+ try:\n+ result = subprocess.run(command, text=True, capture_output=True, timeout=timeout)\n+ return result.returncode, redact((result.stdout + result.stderr).strip())\n+ except Exception as exc:\n+ return 1, str(exc)\n+\n+\n+def convert_mp3(mp3_path: Path) -> None:\n+ if not shutil.which(\"ffmpeg\"):\n+ raise SystemExit(\"ffmpeg is required\")\n+ subprocess.run(\n+ [\n+ \"ffmpeg\",\n+ \"-y\",\n+ \"-hide_banner\",\n+ \"-loglevel\",\n+ \"error\",\n+ \"-i\",\n+ str(mp3_path),\n+ \"-ac\",\n+ \"1\",\n+ \"-ar\",\n+ \"8000\",\n+ \"-acodec\",\n+ \"pcm_s16le\",\n+ str(WAV_PATH),\n+ ],\n+ check=True,\n+ )\n+\n+\n+def write_baresip_config(env: dict[str, str]) -> None:\n+ server = require(env, \"SIP_SERVER\")\n+ username = require(env, \"SIP_USERNAME\")\n+ password = [REDACTED] \"SIP_PASSWORD\")\n+ transport = env.get(\"SIP_TRANSPORT\", \"tcp\")\n+\n+ CONFIG_DIR.mkdir(exist_ok=True)\n+ (CONFIG_DIR / \"config\").write_text(\n+ \"\\n\".join(\n+ [\n+ \"poll_method\\tepoll\",\n+ \"sip_cafile\\t/etc/ssl/certs/ca-certificates.crt\",\n+ \"call_local_timeout\\t120\",\n+ \"call_max_calls\\t1\",\n+ \"audio_source\\taufile,\" + str(WAV_PATH),\n+ \"audio_player\\taufile,\" + str(ROOT / \"incoming.wav\"),\n+ \"audio_alert\\taufile,\" + str(ROOT / \"alert.wav\"),\n+ \"ausrc_srate\\t8000\",\n+ \"auplay_srate\\t8000\",\n+ \"ausrc_channels\\t1\",\n+ \"auplay_channels\\t1\",\n+ \"audio_txmode\\tthread\",\n+ \"audio_buffer\\t20-160\",\n+ \"rtp_ports\\t12000-12100\",\n+ \"module_path\\t/usr/lib/baresip/modules\",\n+ \"module\\tg711.so\",\n+ \"module\\taufile.so\",\n+ \"module_tmp\\tuuid.so\",\n+ \"module_tmp\\taccount.so\",\n+ \"module_app\\tmenu.so\",\n+ \"sip_autoanswer_beep\\tno\",\n+ \"\",\n+ ]\n+ )\n+ )\n+ account = (\n+ f\"<sip:{username}@{server};transport={transport}>\"\n+ f\";auth_user={username};auth_pass=[REDACTED]};answermode=auto\"\n+ \";audio_codecs=PCMA,PCMU\"\n+ f\";audio_source=aufile,{WAV_PATH}\"\n+ f\";audio_player=aufile,{ROOT / 'incoming.wav'}\"\n+ )\n+ (CONFIG_DIR / \"accounts\").write_text(account + \"\\n\")\n+ (CONFIG_DIR / \"contacts\").write_text(\"\\n\")\n+\n+\n+def stream_baresip_output(process: subprocess.Popen) -> None:\n+ assert process.stdout is not None\n+ for line in process.stdout:\n+ append_log(line)\n+\n+\n+def start_baresip() -> subprocess.Popen:\n+ process = subprocess.Popen(\n+ [\"baresip\", \"-4\", \"-f\", str(CONFIG_DIR), \"-v\"],\n+ text=True,\n+ stdout=subprocess.PIPE,\n+ stderr=subprocess.STDOUT,\n+ bufsize=1,\n+ )\n+ threading.Thread(target=stream_baresip_output, args=(process,), daemon=True).start()\n+ return process\n+\n+\n+def supervise_baresip() -> None:\n+ global baresip_process\n+ delay = 1\n+ while not stopping.is_set():\n+ baresip_process = start_baresip()\n+ code = baresip_process.wait()\n+ if stopping.is_set():\n+ break\n+ append_log(f\"baresip exited with {code}; restarting in {delay}s\")\n+ time.sleep(delay)\n+ delay = min(delay * 2, 30)\n+\n+\n+def file_info(path: Path) -> dict:\n+ if not path.exists():\n+ return {\"exists\": False}\n+ stat = path.stat()\n+ return {\n+ \"exists\": True,\n+ \"bytes\": stat.st_size,\n+ \"modified\": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),\n+ }\n+\n+\n+def derived(lines: list[str]) -> dict:\n+ joined = \"\\n\".join(lines)\n+ registrations = re.findall(r\"9415@sip\\.micropoc\\.de: .*?200 OK.*\", joined)\n+ calls = re.findall(r\"Call established: ([^\\n]+)\", joined)\n+ terminations = re.findall(r\"terminated \\(duration: ([^)]+)\\)\", joined)\n+ rtp = re.findall(r\"audio=([0-9]+/[0-9]+) \\(bit/s\\)\", joined)\n+ return {\n+ \"registered\": bool(registrations),\n+ \"last_registration\": registrations[-1] if registrations else None,\n+ \"call_count_in_log_window\": len(calls),\n+ \"last_call\": calls[-1] if calls else None,\n+ \"last_call_duration\": terminations[-1] if terminations else None,\n+ \"last_audio_bitrate\": rtp[-1] if rtp else None,\n+ \"rtp_seen\": \"incoming rtp\" in joined.lower() or \"audio tx pipeline\" in joined.lower() or bool(rtp),\n+ }\n+\n+\n+def status() -> dict:\n+ with log_lock:\n+ lines = list(log_lines)\n+ process = baresip_process\n+ code, hostname = run([\"hostname\"], timeout=2)\n+ code_uptime, uptime = run([\"uptime\", \"-p\"], timeout=2)\n+ return {\n+ \"generated_at\": datetime.now(timezone.utc).isoformat(),\n+ \"host\": hostname if code == 0 else socket.gethostname(),\n+ \"host_uptime\": uptime if code_uptime == 0 else None,\n+ \"extension\": \"9415\",\n+ \"name\": \"richard-sip\",\n+ \"sip_server\": \"sip.micropoc.de\",\n+ \"public_site\": \"https://richard.gulasch.site\",\n+ \"service\": {\n+ \"process\": \"integrated python app\",\n+ \"pid\": os.getpid(),\n+ \"baresip_pid\": process.pid if process else None,\n+ \"baresip_running\": process is not None and process.poll() is None,\n+ },\n+ \"derived\": derived(lines),\n+ \"files\": {\n+ \"mp3\": file_info(ROOT / \"rickroll.mp3\"),\n+ \"wav\": file_info(ROOT / \"rickroll.wav\"),\n+ \"received_audio\": file_info(ROOT / \"incoming.wav\"),\n+ \"config\": file_info(ROOT / \"baresip-runtime\" / \"config\"),\n+ },\n+ \"logs\": lines,\n+ }\n+\n+\n+def render_page(data: dict) -> bytes:\n+ service = data[\"service\"]\n+ derived_info = data[\"derived\"]\n+ online = service.get(\"baresip_running\") and derived_info.get(\"registered\")\n+ cards = [\n+ (\"App\", \"integrated Python app\"),\n+ (\"SIP Process\", \"running\" if service.get(\"baresip_running\") else \"stopped\"),\n+ (\"SIP Registration\", \"registered\" if derived_info.get(\"registered\") else \"not seen\"),\n+ (\"Extension\", f\"{data['extension']} ({data['name']})\"),\n+ (\"SIP Server\", data[\"sip_server\"]),\n+ (\"App PID\", service.get(\"pid\")),\n+ (\"baresip PID\", service.get(\"baresip_pid\")),\n+ (\"Last Call\", derived_info.get(\"last_call\") or \"none in log window\"),\n+ (\"Last Duration\", derived_info.get(\"last_call_duration\") or \"n/a\"),\n+ (\"Last Audio Bitrate\", derived_info.get(\"last_audio_bitrate\") or \"n/a\"),\n+ (\"RTP Seen\", \"yes\" if derived_info.get(\"rtp_seen\") else \"no\"),\n+ (\"Generated\", data[\"generated_at\"]),\n+ ]\n+ card_html = \"\".join(\n+ f\"<section class='card'><div class='label'>{html.escape(str(label))}</div><div class='value'>{html.escape(str(value))}</div></section>\"\n+ for label, value in cards\n+ )\n+ file_rows = \"\".join(\n+ f\"<tr><td>{html.escape(name)}</td><td>{'yes' if info.get('exists') else 'no'}</td><td>{html.escape(str(info.get('bytes', '')))}</td><td>{html.escape(str(info.get('modified', '')))}</td></tr>\"\n+ for name, info in data[\"files\"].items()\n+ )\n+ logs = html.escape(\"\\n\".join(data[\"logs\"][-160:]))\n+ badge_class = \"ok\" if online else \"bad\"\n+ body = f\"\"\"<!doctype html>\n+<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta http-equiv=\"refresh\" content=\"10\"><title>Richard SIP Bot</title>\n+<style>\n+:root {{ color-scheme: dark; --bg:#101014; --panel:#181822; --text:#f3f0e8; --muted:#aaa6bb; --ok:#44d17a; --bad:#ff6b6b; --line:#2a2935; }}\n+body {{ margin:0; font:16px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background:radial-gradient(circle at top left,#30213d,var(--bg) 40%); color:var(--text); }}\n+main {{ max-width:1200px; margin:0 auto; padding:32px 18px 48px; }}\n+h1 {{ font-size:clamp(2rem, 7vw, 5rem); line-height:.9; margin:0 0 12px; letter-spacing:-0.06em; }}\n+.hero {{ display:flex; gap:16px; justify-content:space-between; align-items:flex-end; flex-wrap:wrap; margin-bottom:24px; }}\n+.badge {{ display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border-radius:999px; background:var(--panel); border:1px solid var(--line); font-weight:700; }}\n+.badge::before {{ content:\"\"; width:10px; height:10px; border-radius:50%; background:var(--bad); box-shadow:0 0 18px var(--bad); }} .badge.ok::before {{ background:var(--ok); box-shadow:0 0 18px var(--ok); }}\n+.grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }} .card,.panel {{ background:rgba(24,24,34,.88); border:1px solid var(--line); border-radius:18px; box-shadow:0 18px 50px rgba(0,0,0,.22); }} .card {{ padding:16px; min-height:88px; }}\n+.label {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.08em; }} .value {{ margin-top:8px; overflow-wrap:anywhere; font-size:1.08rem; }} .panel {{ margin-top:18px; padding:18px; }}\n+table {{ width:100%; border-collapse:collapse; }} td, th {{ padding:10px; border-bottom:1px solid var(--line); text-align:left; }} pre {{ overflow:auto; white-space:pre-wrap; word-break:break-word; background:#0b0b10; border:1px solid var(--line); border-radius:14px; padding:14px; max-height:620px; }} a {{ color:#9ed0ff; }}\n+</style></head><body><main>\n+<div class=\"hero\"><div><h1>Richard SIP Bot</h1><p>Integrated Python app: SIP supervision and public live status in one process. Refreshes every 10 seconds.</p></div><div class=\"badge {badge_class}\">{'online' if online else 'attention needed'}</div></div>\n+<div class=\"grid\">{card_html}</div>\n+<section class=\"panel\"><h2>Files</h2><table><thead><tr><th>File</th><th>Exists</th><th>Bytes</th><th>Modified UTC</th></tr></thead><tbody>{file_rows}</tbody></table></section>\n+<section class=\"panel\"><h2>Integrated App Logs</h2><pre>{logs}</pre></section>\n+<section class=\"panel\"><h2>Machine JSON</h2><p><a href=\"/status.json\">/status.json</a></p></section>\n+</main></body></html>\"\"\"\n+ return body.encode()\n+\n+\n+class Handler(BaseHTTPRequestHandler):\n+ def do_GET(self) -> None:\n+ if self.path not in (\"/\", \"/status.json\"):\n+ self.send_error(404)\n+ return\n+ data = status()\n+ if self.path == \"/status.json\":\n+ body = json.dumps(data, indent=2).encode()\n+ content_type = \"application/json; charset=utf-8\"\n+ else:\n+ body = render_page(data)\n+ content_type = \"text/html; charset=utf-8\"\n+ self.send_response(200)\n+ self.send_header(\"Content-Type\", content_type)\n+ self.send_header(\"Cache-Control\", \"no-store\")\n+ self.send_header(\"Content-Length\", str(len(body)))\n+ self.end_headers()\n+ self.wfile.write(body)\n+\n+ def log_message(self, format: str, *args) -> None:\n+ append_log(f\"{self.address_string()} - {format % args}\")\n+\n+\n+def handle_signal(signum, frame) -> None:\n+ stopping.set()\n+ process = baresip_process\n+ if process and process.poll() is None:\n+ process.terminate()\n+\n+\n+def main() -> int:\n+ signal.signal(signal.SIGTERM, handle_signal)\n+ signal.signal(signal.SIGINT, handle_signal)\n+ env = load_env(ROOT / \".env\")\n+ mp3_path = Path(require(env, \"RICKROLL_MP3\")).expanduser().resolve()\n+ if not mp3_path.exists():\n+ raise SystemExit(f\"MP3 file does not exist: {mp3_path}\")\n+ convert_mp3(mp3_path)\n+ write_baresip_config(env)\n+ threading.Thread(target=supervise_baresip, daemon=True).start()\n+ append_log(f\"Serving Richard integrated app on http://{HOST}:{PORT}\")\n+ server = ThreadingHTTPServer((HOST, PORT), Handler)\n+ while not stopping.is_set():\n+ server.handle_request()\n+ server.server_close()\n+ return 0\n+\n+\n+if __name__ == \"__main__\":\n+ raise SystemExit(main())\n*** Update File: /opt/rickroll-sip/run.sh\n@@\n-export PYTHONPATH=\"${PWD}/vendor${PYTHONPATH:+:${PYTHONPATH}}\"\n-exec python3 baresip_supervisor.py\n+exec python3 richard_app.py\n*** Update File: /etc/systemd/system/rickroll-sip.service\n@@\n-Description=Richard SIP Rickroll Bot\n+Description=Richard Integrated SIP Bot and Info Page\n@@\n ExecStart=/opt/rickroll-sip/run.sh\n*** End Patch"
}