commit bbefb8807b8b5b20dae1d19d851e3e5b73a47648 Author: Hermes Agent Date: Wed Jun 17 13:22:19 2026 +0200 🚀 Initial commit: BSN Chatbot — Multi-Channel Intake System WhatsApp webhook, admin dashboard, public frontend, media handling, LLM auto-summarize, hall/stand detection, iOS-style backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e907d65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +*.pyc +__pycache__/ +media/ +*.db +app.py.bak* +*.bak +.cloudflare_token diff --git a/app.py b/app.py new file mode 100644 index 0000000..5ddcbd7 --- /dev/null +++ b/app.py @@ -0,0 +1,1939 @@ +#!/usr/bin/env python3 +""" +BSN Chatbot — Multi-Channel Intake System +========================================== +WhatsApp webhook receiver + admin dashboard + knowledge query API. + +Architecture: + WhatsApp → Meta Cloud API → POST /whatsapp/webhook → SQLite + Admin: GET /admin → filterable table of all submissions + Query: POST /query → RAG-style answer from knowledge table + +Usage: + source .env && python3 app.py +""" + +import json +import os +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +import requests # type: ignore +from flask import Flask, request, jsonify, g # type: ignore + +# ── Config ────────────────────────────────────────────────────────── +BASE_DIR = Path(__file__).parent + +# Load .env file if it exists (for tokens that can't be passed via env) +_ENV_FILE = BASE_DIR / ".env" +if _ENV_FILE.exists(): + with open(_ENV_FILE) as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _key, _, _val = _line.partition("=") + _key = _key.strip() + _val = _val.strip().strip('"').strip("'") + if _key not in os.environ or not os.environ[_key]: + os.environ[_key] = _val + +DB_PATH = os.environ.get("DATABASE_PATH", str(BASE_DIR / "bsn_intake.db")) +VERIFY_TOKEN = os.environ.get("VERIFY_TOKEN", "bsn_verify_2026") +META_TOKEN = os.environ.get("META_TOKEN", "") +PHONE_NUMBER_ID = os.environ.get("META_PHONE_NUMBER_ID", "1152708791258718") +PORT = int(os.environ.get("PORT", "5002")) + +MEDIA_DIR = BASE_DIR / "media" +MEDIA_DIR.mkdir(exist_ok=True) + +app = Flask(__name__) + + +# ── Hall extraction ────────────────────────────────────────────────── +def extract_halle(text: str) -> str | None: + """Extract SPIEL Essen hall+stand from text. Returns e.g. 'H3 B123' or None.""" + import re + if not text: + return None + # Pattern: H[1-7] optionally followed by stand letter+number + # Matches: "H3", "Halle 3", "H 3", "H3 B123", "Halle 6 Stand A42" + patterns = [ + # Halle with stand: H3 B123, Halle 6 A42, H 4 C7 + r'(?:H|Halle)\s*([1-7])\s*[,\-]?\s*(?:Stand\s*)?([A-Z]\d{1,4})', + # Just hall: H3, Halle 6, H 7 + r'(?:H|Halle)\s*([1-7])\b', + # Stand alone: B123 (only if preceded by "Stand" or after hall context) + r'Stand\s*([A-Z]\d{1,4})', + ] + for pat in patterns: + m = re.search(pat, text, re.IGNORECASE) + if m: + groups = m.groups() + if len(groups) == 2 and groups[1]: + return f"H{groups[0]} {groups[1]}" + elif groups[0]: + return f"H{groups[0]}" + return None + + +# ── Game title extraction from cover images ────────────────────────── +def extract_game_title_from_image(image_path: str) -> str | None: + """Use Gemini Flash vision to read the board game title from a cover image.""" + import base64 + import openai + + if not os.path.exists(image_path): + return None + + try: + with open(image_path, "rb") as f: + img_b64 = base64.b64encode(f.read()).decode() + except Exception: + return None + + # Read API config + config_path = os.path.expanduser("~/.hermes/config.yaml") + try: + with open(config_path) as f: + import yaml + cfg = yaml.safe_load(f) + api_key = cfg["providers"]["datenhimmel"]["api_key"] + base_url = cfg["providers"]["datenhimmel"]["base_url"] + except Exception: + return None + + client = openai.OpenAI(base_url=base_url, api_key=api_key) + try: + r = client.chat.completions.create( + model="gemini-3-flash-preview:cloud", + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": ( + "This is a board game cover. What is the EXACT game title?\n" + "Reply with ONLY the title, nothing else. " + "If it's a known game, use the official German title if it exists.\n" + "Examples: 'Cascadia', 'Flügelschlag', 'Die Siedler von Catan', 'Azul'" + )}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}, + ] + }], + max_tokens=50, + temperature=0.1, + ) + title = r.choices[0].message.content.strip() + # Clean up: remove quotes, extra text, limit length + title = title.strip('"').strip("'").strip() + if len(title) > 120: + title = title[:120] + return title if title else None + except Exception as e: + print(f"[vision error] {e}", file=sys.stderr) + return None + + +# ── Database helpers ──────────────────────────────────────────────── +def get_db() -> sqlite3.Connection: + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + g.db.execute("PRAGMA journal_mode=WAL") + return g.db + + +@app.teardown_appcontext +def close_db(_exception=None): + db = g.pop("db", None) + if db: + db.close() + + +# ── WhatsApp API helpers ──────────────────────────────────────────── +def _gdpr_delete_sender(db: sqlite3.Connection, sender_id: str): + """GDPR: Delete all submissions from a sender, leave a placeholder.""" + # Delete media files (supports ||-separated paths) + rows = db.execute( + "SELECT media_path FROM submissions WHERE sender_id = ? AND channel = 'whatsapp'", + (sender_id,), + ).fetchall() + for r in rows: + if r["media_path"]: + for p in r["media_path"].split("||"): + if os.path.exists(p): + os.remove(p) + # Delete all submissions from this sender + db.execute( + "DELETE FROM submissions WHERE sender_id = ? AND channel = 'whatsapp'", + (sender_id,), + ) + # Insert GDPR placeholder + db.execute( + """INSERT INTO submissions (channel, sender_id, type, content, status, category) + VALUES ('whatsapp', ?, 'text', ?, 'gdpr_deleted', 'dsgvo')""", + (sender_id, "Nutzer hat die Löschung verlangt"), + ) + + +def send_whatsapp_message(to: str, text: str) -> dict: + """Send a text message back via WhatsApp Cloud API.""" + url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/messages" + headers = { + "Authorization": f"Bearer {META_TOKEN}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "to": to, + "type": "text", + "text": {"body": text}, + } + r = requests.post(url, headers=headers, json=payload, timeout=15) + return r.json() + + +def generate_thorsten_tts(text: str) -> str | None: + """Generate German TTS using Piper Thorsten voice. Returns path to OGG file or None.""" + import subprocess + import tempfile + + PIPER = "/home/hermes/.local/bin/piper" + MODEL = "/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx" + FFMPEG = "/home/hermes/.local/bin/ffmpeg" + LD_PATH = "/home/hermes/.local/lib" + ESPEAK_PATH = "/home/hermes/.local/share/espeak-ng-data" + + ogg_path = str(MEDIA_DIR / f"onboarding_{datetime.now().strftime('%Y%m%d%H%M%S')}.ogg") + try: + p1 = subprocess.Popen( + [PIPER, "--model", MODEL, "--output-raw", "-"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={"LD_LIBRARY_PATH": LD_PATH, "ESPEAK_DATA_PATH": ESPEAK_PATH, "PATH": os.environ.get("PATH", "")}, + ) + p2 = subprocess.Popen( + [FFMPEG, "-f", "s16le", "-ar", "22050", "-ac", "1", "-i", "-", + "-c:a", "libopus", "-b:a", "16k", "-f", "ogg", ogg_path, "-y"], + stdin=p1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + p1.stdin.write(text.encode("utf-8")) + p1.stdin.close() + p1.stdout.close() + p2.communicate(timeout=30) + p1.wait(timeout=10) + if os.path.exists(ogg_path) and os.path.getsize(ogg_path) > 100: + return ogg_path + except Exception as e: + print(f"[tts error] {e}", file=sys.stderr) + return None + + +def upload_whatsapp_media(file_path: str, mime_type: str = "audio/ogg") -> str | None: + """Upload media to WhatsApp Cloud API. Returns media_id or None.""" + url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/media" + headers = {"Authorization": f"Bearer {META_TOKEN}"} + try: + with open(file_path, "rb") as f: + files = { + "file": (os.path.basename(file_path), f, mime_type), + } + data = {"messaging_product": "whatsapp"} + r = requests.post(url, headers=headers, files=files, data=data, timeout=20) + result = r.json() + return result.get("id") + except Exception as e: + print(f"[upload error] {e}", file=sys.stderr) + return None + + +def send_whatsapp_audio(to: str, media_id: str) -> dict: + """Send an audio voice message via WhatsApp Cloud API.""" + url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/messages" + headers = { + "Authorization": f"Bearer {META_TOKEN}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "to": to, + "type": "audio", + "audio": {"id": media_id}, + } + r = requests.post(url, headers=headers, json=payload, timeout=15) + return r.json() + + +def download_media(media_id: str) -> tuple[str | None, str | None]: + """Download media from WhatsApp. Returns (local_path, mime_type) or (None, None).""" + url = f"https://graph.facebook.com/v25.0/{media_id}" + headers = {"Authorization": f"Bearer {META_TOKEN}"} + r = requests.get(url, headers=headers, timeout=10) + if r.status_code != 200: + print(f"[download_media] Meta API returned {r.status_code}: {r.text[:200]}", file=sys.stderr) + return None, None + data = r.json() + media_url = data.get("url") + mime_type = data.get("mime_type", "unknown") + + if not media_url: + print(f"[download_media] No URL in Meta response for media {media_id}", file=sys.stderr) + return None, None + + r2 = requests.get(media_url, headers=headers, timeout=30) + if r2.status_code != 200: + print(f"[download_media] Media download returned {r2.status_code} for {media_id}", file=sys.stderr) + return None, None + + ext = mime_type.split("/")[-1] if "/" in mime_type else "bin" + filename = f"{media_id}.{ext}" + local_path = MEDIA_DIR / filename + local_path.write_bytes(r2.content) + + return str(local_path), mime_type + + +def auto_process_bg(db_path: str, submission_id: int): + """Background: after transcription, run LLM to summarize + extract halle/stand.""" + import yaml as _yaml + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT content, halle FROM submissions WHERE id=?", (submission_id,)).fetchone() + if not row or not row["content"]: + conn.close() + return + + text = (row["content"] or "").strip() + # Skip if text is too short or placeholder + if len(text) < 20: + conn.close() + return + + # Read API key + config_path = os.path.expanduser("~/.hermes/config.yaml") + api_key = "" + try: + with open(config_path) as f: + cfg = _yaml.safe_load(f) + api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "") + except Exception: + pass + if not api_key: + conn.close() + return + + prompt = ( + "Du bist Redaktionsassistent für brettspiel-news.de. Analysiere folgende Community-Nachricht " + "und antworte NUR mit diesem JSON-Format, kein weiterer Text:\n" + '{"summary": "Kurze Zusammenfassung in 1-2 Sätzen, max 250 Zeichen, journalistisch, Präsens", ' + '"halle": "Halle und Stand im Format z.B. H3 B123, oder null wenn nichts erkannt", ' + '"spiel_titel": "Name des Brettspiels falls genannt, sonst null"}\n\n' + f"Nachricht:\n{text[:2000]}" + ) + + import requests as _req + r = _req.post( + "https://ui.datenhimmel.work/api/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": "deepseek-v4-flash:cloud", "messages": [{"role": "user", "content": prompt}], "max_tokens": 300, "temperature": 0.2}, + timeout=20, + ) + if r.status_code != 200: + conn.close() + return + + import json as _json + data = r.json() + result_text = data["choices"][0]["message"]["content"].strip() + # Parse JSON from response (may have markdown wrapping) + if "```" in result_text: + result_text = result_text.split("```")[1] + if result_text.startswith("json"): + result_text = result_text[4:] + result_text = result_text.strip() + parsed = _json.loads(result_text) + + summary = parsed.get("summary", "").strip() + llm_halle = parsed.get("halle", "").strip() if parsed.get("halle") and parsed["halle"] != "null" else None + spiel_titel = parsed.get("spiel_titel", "").strip() if parsed.get("spiel_titel") and parsed["spiel_titel"] != "null" else None + + # Build update fields + existing = conn.execute("SELECT halle, spiel_titel, summary FROM submissions WHERE id=?", (submission_id,)).fetchone() + + updates = [] + params = [] + if summary: + updates.append("summary=?") + params.append(summary[:500]) + if llm_halle and not existing["halle"]: + updates.append("halle=?") + params.append(llm_halle) + if spiel_titel and not existing["spiel_titel"]: + updates.append("spiel_titel=?") + params.append(spiel_titel) + + if updates: + params.append(submission_id) + conn.execute(f"UPDATE submissions SET {', '.join(updates)} WHERE id=?", params) + conn.commit() + conn.close() + except Exception as e: + print(f"[auto_process error] {e}", file=sys.stderr) + + +def transcribe_audio(audio_path: str) -> str | None: + """Transcribe an audio file using faster-whisper (local). Returns transcription text or None.""" + import subprocess + import json + + WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python" + script = ( + "from faster_whisper import WhisperModel\n" + "import sys, json\n" + "model = WhisperModel('base', device='cpu', compute_type='int8')\n" + "segments, info = model.transcribe(sys.argv[1])\n" + "text = ' '.join(seg.text.strip() for seg in segments)\n" + "print(json.dumps({'text': text, 'lang': info.language}))\n" + ) + try: + result = subprocess.run( + [WHISPER_PYTHON, "-c", script, audio_path], + capture_output=True, text=True, timeout=120, + ) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout.strip()) + return data.get("text", "") + else: + print(f"[transcribe error] {result.stderr[:200]}", file=sys.stderr) + return None + except Exception as e: + print(f"[transcribe exception] {e}", file=sys.stderr) + return None + + +def _transcribe_bg(db_path: str, submission_id: int, audio_path: str): + """Background thread: transcribe audio and update the DB record.""" + text = transcribe_audio(audio_path) + if text: + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT content FROM submissions WHERE id=?", (submission_id,) + ).fetchone() + if row: + old = row["content"] or "" + new_content = old.replace( + "[🎤 Sprachnachricht — Transkription läuft...]", + f"🎤 {text}", + ) + if new_content == old: # fallback if placeholder not found (e.g. old records) + new_content = old + f"\n🎤 Transkription: {text}" + conn.execute( + "UPDATE submissions SET content=? WHERE id=?", + (new_content, submission_id), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"[transcribe bg error] {e}", file=sys.stderr) + # Auto-summarize + hall extraction after transcription + auto_process_bg(db_path, submission_id) + + +def transcribe_video(video_path: str) -> str | None: + """Extract audio from video, then transcribe. Returns text or None.""" + import subprocess + import tempfile + import json + + WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python" + FFMPEG = "/home/hermes/.local/bin/ffmpeg" + LD_PATH = "/home/hermes/.local/lib" + + wav_path = None + try: + # Extract audio to temp WAV file + fd, wav_path = tempfile.mkstemp(suffix=".wav") + os.close(fd) + result = subprocess.run( + [FFMPEG, "-i", video_path, "-vn", "-acodec", "pcm_s16le", + "-ar", "16000", "-ac", "1", wav_path, "-y"], + capture_output=True, text=True, timeout=60, + env={"LD_LIBRARY_PATH": LD_PATH, "PATH": os.environ.get("PATH", "")}, + ) + if not os.path.exists(wav_path) or os.path.getsize(wav_path) < 100: + return None + + # Now transcribe the extracted audio + return transcribe_audio(wav_path) + except Exception as e: + print(f"[video transcribe error] {e}", file=sys.stderr) + return None + finally: + if wav_path and os.path.exists(wav_path): + os.unlink(wav_path) + + +def generate_video_thumbnail(video_path: str) -> str | None: + """Extract a thumbnail frame from a video at 1 second. Returns path to .jpg or None.""" + import subprocess + FFMPEG = "/home/hermes/.local/bin/ffmpeg" + thumb_path = video_path + ".thumb.jpg" + # Don't regenerate if exists and is not empty + if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100: + return thumb_path + try: + subprocess.run( + [FFMPEG, "-i", video_path, "-ss", "00:00:01", "-vframes", "1", + "-q:v", "5", thumb_path, "-y"], + capture_output=True, timeout=30, check=True + ) + if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100: + return thumb_path + except Exception: + pass + return None + + +def _transcribe_video_bg(db_path: str, submission_id: int, video_path: str): + """Background thread: extract audio from video, transcribe, update DB.""" + text = transcribe_video(video_path) + if text: + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT content FROM submissions WHERE id=?", (submission_id,) + ).fetchone() + if row: + old = row["content"] or "" + new_content = old.replace( + "[🎬 Video — Transkription läuft...]", + f"🎬 {text}", + ) + if new_content == old: + new_content = old + f"\n🎬 Transkription: {text}" + conn.execute( + "UPDATE submissions SET content=? WHERE id=?", + (new_content, submission_id), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"[video transcribe bg error] {e}", file=sys.stderr) + # Auto-summarize + hall extraction after transcription + auto_process_bg(db_path, submission_id) + + +# ── Routes ────────────────────────────────────────────────────────── +@app.route("/whatsapp/webhook", methods=["GET", "POST"]) +def whatsapp_webhook(): + """Meta WhatsApp Webhook. GET=verification, POST=incoming messages.""" + if request.method == "GET": + mode = request.args.get("hub.mode") + token = request.args.get("hub.verify_token") + challenge = request.args.get("hub.challenge") + if mode == "subscribe" and token == VERIFY_TOKEN: + return challenge, 200 + return "Forbidden", 403 + + # POST: Incoming message + body = request.get_json(force=True, silent=True) + if not body: + return "OK", 200 + + try: + entries = body.get("entry", []) + db = get_db() + + for entry in entries: + changes = entry.get("changes", []) + for change in changes: + value = change.get("value", {}) + contacts = value.get("contacts", [{}]) + messages = value.get("messages", []) + + for msg in messages: + sender_id = msg.get("from", "unknown") + sender_name = contacts[0].get("profile", {}).get("name", "unknown") + msg_type = msg.get("type", "text") + content = "" + media_path = None + spiel_titel = None + category_override = None # set to "ausverkauft" for #out + + if msg_type == "text": + content = msg.get("text", {}).get("body", "") + elif msg_type == "image": + content = msg.get("image", {}).get("caption", "[Bild ohne Text]") + media_id = msg.get("image", {}).get("id") + if media_id: + media_path, _ = download_media(media_id) + elif msg_type == "audio": + content = "[🎤 Sprachnachricht — Transkription läuft...]" + media_id = msg.get("audio", {}).get("id") + if media_id: + media_path, _ = download_media(media_id) + elif msg_type == "video": + content = msg.get("video", {}).get( + "caption", "[🎬 Video — Transkription läuft...]" + ) + media_id = msg.get("video", {}).get("id") + if media_id: + media_path, _ = download_media(media_id) + elif msg_type == "document": + content = msg.get("document", {}).get( + "caption", "[Dokument]" + ) + media_id = msg.get("document", {}).get("id") + if media_id: + media_path, _ = download_media(media_id) + else: + content = f"[{msg_type}]" + + # GDPR: "Meine Daten löschen" trigger + if msg_type == "text" and content.strip().lower() == "meine daten löschen": + _gdpr_delete_sender(db, sender_id) + if META_TOKEN: + try: + send_whatsapp_message( + sender_id, + "✅ Alle deine Daten wurden vollständig und endgültig vom Server gelöscht. " + "Es befinden sich keinerlei personenbezogene Daten mehr von dir bei uns. " + "Lediglich ein anonymer Vermerk über die erfolgte Löschung bleibt für " + "unsere Dokumentationspflicht.", + ) + except Exception: + pass + continue + + # Check for existing thread from this sender (5-min window) + recent = db.execute( + """SELECT id, content, media_path, halle FROM submissions + WHERE channel='whatsapp' AND sender_id=? AND status != 'gdpr_deleted' + ORDER BY created_at DESC LIMIT 1""", + (sender_id,), + ).fetchone() + + is_new_user = db.execute( + "SELECT COUNT(*) FROM submissions WHERE channel='whatsapp' AND sender_id=? AND status != 'gdpr_deleted'", + (sender_id,), + ).fetchone()[0] == 0 + + THREAD_WINDOW_MINUTES = 5 + thread_found = False + if recent: + age_check = db.execute( + "SELECT (strftime('%s','now') - strftime('%s',created_at)) < ? FROM submissions WHERE id=?", + (THREAD_WINDOW_MINUTES * 60, recent["id"]), + ).fetchone() + if age_check and age_check[0]: + thread_found = True + + if thread_found: + # Append to existing thread + prefix = { + "text": "", "image": "\n[🖼️ Bild] ", + "audio": "\n[🎤 Sprachnachricht]", + "video": "\n[🎬 Video] ", "document": "\n[📎 Dokument] ", + }.get(msg_type, f"\n[{msg_type}] ") + new_content = (recent["content"] or "") + prefix + content + # Re-extract halle from combined content + new_halle = extract_halle(new_content) or recent["halle"] + db.execute( + "UPDATE submissions SET content=?, halle=?, created_at=CURRENT_TIMESTAMP WHERE id=?", + (new_content, new_halle, recent["id"]), + ) + # Merge media paths (keep all, separated by ||) + if media_path: + merged_media = media_path + if recent["media_path"]: + merged_media = recent["media_path"] + "||" + media_path + db.execute( + "UPDATE submissions SET media_path=? WHERE id=?", + (merged_media, recent["id"]), + ) + else: + # New thread + halle = extract_halle(content) + cat = category_override or None + db.execute( + """INSERT INTO submissions + (channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel) + VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)""", + ("whatsapp", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel), + ) + + # Auto-reply: only on NEW threads, not appends + if not thread_found and META_TOKEN: + try: + if is_new_user: + reply = ( + f"🎲 Hey {sender_name}! Cool, dass du da bist — willkommen in der " + f"brettspiel-news.de Community!\n\n" + f"Schick uns einfach, was dich bewegt: 💬 Text, 🎤 Sprachnachrichten, " + f"🖼️ Fotos oder 🎬 Videos — alles rund um Brettspiele. Wir schauen uns alles an!\n\n" + f"✍️ Nenn uns deinen Vornamen, wenn du magst — das ist deine Einwilligung, " + f"dass wir dich als Quelle nennen, falls wir deinen Beitrag verwenden. " + f"Ohne Namen schreiben wir „anonyme Quelle\".\n\n" + f"🔒 „Meine Daten löschen\" genügt — und alles ist sofort und vollständig weg." + ) + send_whatsapp_message(sender_id, reply) + # Thorsten voice: same text but without emojis + import re + tts_text = re.sub(r'[^\x00-\x7F\u00C0-\u00FF\u0100-\u017F\u0180-\u024F\u1E00-\u1EFF\u2018-\u201F\u2013\u2014\u2026\u00A0\u00AB\u00BB\u20AC]', '', reply) + tts_text = re.sub(r' +', ' ', tts_text).strip() + audio_path = generate_thorsten_tts(tts_text) + if audio_path: + media_id = upload_whatsapp_media(audio_path) + if media_id: + send_whatsapp_audio(sender_id, media_id) + else: + reply = ( + f"📨 Danke {sender_name}! Ist angekommen — " + f"wir kümmern uns drum." + ) + send_whatsapp_message(sender_id, reply) + except Exception: + pass + + # Transcribe audio in background (non-blocking) + if msg_type == "audio" and media_path: + import threading + sub_id = recent["id"] if thread_found else db.execute( + "SELECT last_insert_rowid()" + ).fetchone()[0] + threading.Thread( + target=_transcribe_bg, + args=(DB_PATH, sub_id, media_path), + daemon=True, + ).start() + + # Transcribe video in background + if msg_type == "video" and media_path: + import threading + sub_id = recent["id"] if thread_found else db.execute( + "SELECT last_insert_rowid()" + ).fetchone()[0] + threading.Thread( + target=_transcribe_video_bg, + args=(DB_PATH, sub_id, media_path), + daemon=True, + ).start() + + # Auto-summarize text/image submissions (LLM hall + summary) + if msg_type in ("text", "image") and not thread_found and content and len(content.strip()) > 20: + import threading + sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0] + # Only if regex didn't already find a halle + if not halle: + threading.Thread( + target=auto_process_bg, + args=(DB_PATH, sub_id), + daemon=True, + ).start() + + db.commit() + except Exception as e: + print(f"[webhook error] {e}", file=sys.stderr) + return "OK", 200 + + return "OK", 200 + + +@app.route("/admin") +def admin_dashboard(): + """Internal admin dashboard — filterable table of all submissions.""" + db = get_db() + + channel_filter = request.args.get("channel", "") + status_filter = request.args.get("status", "") + category_filter = request.args.get("category", "") + search = request.args.get("q", "") + halle_filter = request.args.get("halle", "") + + query = "SELECT * FROM submissions WHERE deleted_at IS NULL" + params: list = [] + + if channel_filter: + query += " AND channel = ?" + params.append(channel_filter) + if status_filter: + query += " AND status = ?" + params.append(status_filter) + if category_filter: + query += " AND category = ?" + params.append(category_filter) + if halle_filter: + query += " AND halle = ?" + params.append(halle_filter) + if search: + query += " AND (content LIKE ? OR sender_id LIKE ?)" + params.extend([f"%{search}%", f"%{search}%"]) + + query += " ORDER BY created_at DESC LIMIT 200" + rows = db.execute(query, params).fetchall() + + channels = [ + r[0] + for r in db.execute("SELECT DISTINCT channel FROM submissions").fetchall() + ] + statuses = [ + r[0] + for r in db.execute("SELECT DISTINCT status FROM submissions").fetchall() + ] + categories = [ + r[0] + for r in db.execute( + "SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL" + ).fetchall() + ] + + # Channel totals + totals_html = "".join( + f'
' + f'{db.execute("SELECT COUNT(*) FROM submissions WHERE channel=?", (c,)).fetchone()[0]}' + f" {c}
" + for c in channels + ) + + # Get distinct halle values + hallen = [r[0] for r in db.execute("SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL ORDER BY halle").fetchall()] + + # Filter dropdowns + filter_html = f""" +
+ + + + + + + Reset +
+ """ + + # Status display names (German) + status_names = { + "new": "Neu", "processing": "In Bearbeitung", "done": "Erledigt", + "flagged": "Markiert", "gdpr_deleted": "DSGVO-Gelöscht", "published": "Veröffentlicht", + } + table_rows = "" + for r in rows: + content_snippet = (r["content"] or "")[:120] + media_icon = {"image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(r["type"], "") + st_label = status_names.get(r["status"], r["status"]) + table_rows += f""" + + {r['id']} + {r['channel']} + {r['sender_id']} + {media_icon} {r['type']} + {r['halle'] or '-'} + {r['spiel_titel'] or '-'} + {content_snippet} + {st_label} + {r['category'] or '-'} + {r['created_at']} + + """ + + # Mobile card rows + mobile_cards = "" + for r in rows: + content_snippet = (r["content"] or "")[:80] + media_icon = {"image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(r["type"], "") + st_label = status_names.get(r["status"], r["status"]) + mobile_cards += f""" +
#{r['id']}{st_label}
+
{media_icon} {r['type']} · {r['channel']}
+
{'📍 ' + r['halle'] if r['halle'] else ''}{' 🎯 ' + r['spiel_titel'] if r['spiel_titel'] else ''}
+
{content_snippet}
+
{r['created_at']}
+
""" + + return f""" + + + +BSN Intake — Admin + + + + +
+
+

📨 BSN Intake

+
{len(rows)} Submissions | 🧪 Test | 🌐 Frontend | ⚙️ Health
+
+
+
+
{totals_html}
+
{filter_html.replace('
','').replace('style="padding:0.3rem 0.8rem;"','').replace('style="padding:0.3rem;"','').replace('onchange="this.form.submit()"','onchange="this.form.submit()"')}
Reset
+
{mobile_cards}
+
+{table_rows}
IDSenderTyp📍 Halle🎯 SpielInhaltStatusZeit
+
+
+ + + +""" + + +@app.route("/test") +def test_page(): + """Page with a curl snippet for simulated testing.""" + return """ + +Simulierter Test + + +

🧪 Simulierter WhatsApp Test

+

Dieser curl-Befehl sendet eine Fake-WhatsApp-Nachricht an den lokalen Webhook:

+
curl -X POST http://127.0.0.1:5002/whatsapp/webhook \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "object": "whatsapp_business_account",
+    "entry": [{
+      "id": "TEST",
+      "changes": [{
+        "value": {
+          "messaging_product": "whatsapp",
+          "metadata": {
+            "display_phone_number": "TEST",
+            "phone_number_id": "TEST"
+          },
+          "contacts": [{
+            "profile": {"name": "Max Mustermann"}
+          }],
+          "messages": [{
+            "from": "+491234567890",
+            "id": "wamid.test123",
+            "timestamp": "1718550000",
+            "type": "text",
+            "text": {"body": "Hallo! Gibt es Catan noch in Halle 5?"}
+          }]
+        }
+      }]
+    }]
+  }'
+

← Zurück zum Dashboard

+ +""" + + +@app.route("/health") +def health(): + return jsonify( + { + "status": "ok", + "db": str(DB_PATH), + "whatsapp_configured": bool(META_TOKEN), + "phone_number_id": PHONE_NUMBER_ID, + } + ) + + +@app.route("/submission/") +def submission_detail(sub_id: int): + """Detail view for a single submission.""" + db = get_db() + row = db.execute("SELECT * FROM submissions WHERE id = ?", (sub_id,)).fetchone() + if not row: + return "

Nicht gefunden

← Zurück

", 404 + + import base64 + + # Media preview with per-item publish checkboxes + media_html = "" + if row["media_path"]: + paths = row["media_path"].split("||") + # Parse existing publish flags + flags_str = row["media_publish_flags"] or "" + flags = flags_str.split(",") if flags_str else ["1"] * len(paths) + # Pad flags if needed + while len(flags) < len(paths): + flags.append("1") + mime_map = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", + ".mp4": "video/mp4", ".webm": "video/webm", + ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav", + } + for i, path in enumerate(paths): + checked = "checked" if flags[i] == "1" else "" + if not os.path.exists(path): + media_html += f'

📁 Datei nicht gefunden: {os.path.basename(path)}

' + continue + _, ext = os.path.splitext(path) + mime = mime_map.get(ext.lower(), "application/octet-stream") + try: + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + src = f"data:{mime};base64,{b64}" + media_html += f'
' + # Checkbox overlay + media_html += f'' + if mime.startswith("image/"): + media_html += f'' + elif mime.startswith("video/"): + # Generate thumbnail for poster + thumb = generate_video_thumbnail(path) + poster_attr = "" + if thumb: + try: + with open(thumb, "rb") as tf: + thumb_b64 = base64.b64encode(tf.read()).decode() + poster_attr = f' poster="data:image/jpeg;base64,{thumb_b64}"' + except Exception: + pass + media_html += f'' + elif mime.startswith("audio/"): + media_html += f'' + else: + media_html += f'

📎 {os.path.basename(path)}

' + media_html += f'
' + except Exception as e: + media_html += f'

Fehler: {e}

' + + type_emoji = {"text": "💬", "image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(row["type"], "📌") + + return f""" + + + +#{row['id']} — BSN + + + +
+
+ ← Zurück +

{type_emoji} #{row['id']}

{row['channel']} · {row['created_at']}
+ {f'
📍 {row["halle"]}
' if row['halle'] else ''} +
+
+
+ +
+
Absender
{row['sender_name'] or row['sender_id']}({row['sender_id']})
+
Typ
{type_emoji} {row['type']}
+
Inhalt
{row['content'] or '(leer)'}
+
Status
{row['status']}
+
Kategorie
{row['category'] or '-'}
+
📍 Halle / Stand
{row['halle'] or '– nicht erkannt –'}
+
🎯 Spieltitel
{row['spiel_titel'] or '–'}
+
+ +
+
✍️ Für Frontend-Veröffentlichung
+
+
+
📍 Halle / Stand
+ +
+
+
🎯 Spieltitel
+ +
+
+
Zusammenfassung (LLM/Mensch)
+ +
+
+
Name zur Veröffentlichung
+ +
+
+ + +
+
+
+ +
+
🖼️ Medien verwalten
+

Nur Medien mit Haken erscheinen im Frontend.

+
+ + {media_html} + +
+
+ +
+
🗑️ Gefahrenzone
+

Löscht den Eintrag und alle Mediendateien unwiderruflich.

+ + +
+ +
+ +
+
+ + +
+
+ + + + + +""" + + +@app.route("/submission//update-summary", methods=["POST"]) +def submission_update_summary(sub_id: int): + """Update summary, publish_name, and change status.""" + db = get_db() + action = request.form.get("action", "save") + summary = request.form.get("summary", "").strip() + publish_name = request.form.get("publish_name", "").strip() + halle = request.form.get("halle", "").strip() + spiel_titel = request.form.get("spiel_titel", "").strip() + + if action == "publish": + db.execute( + "UPDATE submissions SET status='published', published_at=CURRENT_TIMESTAMP WHERE id=?", + (sub_id,), + ) + elif action == "done": + db.execute("UPDATE submissions SET status='done' WHERE id=?", (sub_id,)) + elif action == "save": + db.execute( + "UPDATE submissions SET summary=?, publish_name=?, halle=?, spiel_titel=? WHERE id=?", + (summary or None, publish_name or None, halle or None, spiel_titel or None, sub_id), + ) + elif action == "save_media": + # Collect media publish flags from form + import re + flags_dict = {} + for key in request.form: + m = re.match(r'media_publish_(\d+)', key) + if m: + flags_dict[int(m.group(1))] = "1" + # Get current media paths count + row = db.execute("SELECT media_path FROM submissions WHERE id=?", (sub_id,)).fetchone() + if row and row["media_path"]: + n = len(row["media_path"].split("||")) + flags = [flags_dict.get(i, "0") for i in range(n)] + db.execute( + "UPDATE submissions SET media_publish_flags=? WHERE id=?", + (",".join(flags), sub_id), + ) + + db.commit() + return """""" + + +@app.route("/submission//done", methods=["POST"]) +def submission_done(sub_id: int): + """Mark a submission as done/bearbeitet.""" + db = get_db() + db.execute("UPDATE submissions SET status='done' WHERE id=?", (sub_id,)) + db.commit() + return """ + + + +
✅ Beitrag als erledigt markiert
+ + +""" + + +@app.route("/submission//generate-summary", methods=["POST"]) +def generate_llm_summary(sub_id: int): + """Call DeepSeek V4 Flash to summarize the submission content.""" + db = get_db() + row = db.execute("SELECT * FROM submissions WHERE id = ?", (sub_id,)).fetchone() + if not row: + return jsonify({"error": "Nicht gefunden"}), 404 + + content = (row["content"] or "").strip() + if not content: + return jsonify({"summary": ""}) + + # Truncate very long content + if len(content) > 3000: + content = content[:3000] + "..." + + import requests as req + import yaml + + # Read API key from Hermes config + config_path = os.path.expanduser("~/.hermes/config.yaml") + api_key = "" + try: + with open(config_path) as f: + cfg = yaml.safe_load(f) + api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "") + except Exception: + pass + + if not api_key: + return jsonify({"error": "API-Key nicht konfiguriert"}), 500 + + prompt = ( + "Du bist Redaktionsassistent für brettspiel-news.de. " + "Fasse folgende Community-Nachricht in 2-3 Sätzen zusammen. " + "Schreibe im Präsens, journalistisch, ohne Floskeln. " + "Kein 'Der Nutzer schreibt...' oder 'In dieser Nachricht...'. " + "Maximal 250 Zeichen.\n\n" + f"Nachricht:\n{content}" + ) + + try: + r = req.post( + "https://ui.datenhimmel.work/api/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "deepseek-v4-flash:cloud", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 300, + "temperature": 0.3, + }, + timeout=30, + ) + if r.status_code == 200: + data = r.json() + summary = data["choices"][0]["message"]["content"].strip() + return jsonify({"summary": summary}) + else: + return jsonify({"error": f"API-Fehler: {r.status_code}"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/submission//delete", methods=["POST"]) +def submission_delete(sub_id: int): + """Delete a submission and its media file.""" + db = get_db() + row = db.execute("SELECT media_path FROM submissions WHERE id = ?", (sub_id,)).fetchone() + if row and row["media_path"]: + for p in row["media_path"].split("||"): + if os.path.exists(p): + os.remove(p) + db.execute("DELETE FROM submissions WHERE id = ?", (sub_id,)) + db.commit() + return f""" + + + +
🗑️ Beitrag #{sub_id} gelöscht
+ + +""" + + +@app.route("/") +def public_frontend(): + """Public frontend showing published submissions — SPIEL Essen inspired.""" + import base64 + db = get_db() + + # Filters + halle_filter = request.args.get("halle", "").strip() + cat_filter = request.args.get("cat", "").strip() + + query = "SELECT * FROM submissions WHERE status='published' AND deleted_at IS NULL" + params = [] + if halle_filter: + query += " AND halle LIKE ?" + params.append(f"{halle_filter}%") + if cat_filter: + query += " AND category = ?" + params.append(cat_filter) + query += " ORDER BY published_at DESC LIMIT 50" + rows = db.execute(query, params).fetchall() + + # Get available filter values + hallen = [r[0] for r in db.execute( + "SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL AND status='published' ORDER BY halle" + ).fetchall()] + categories = [r[0] for r in db.execute( + "SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL AND status='published' ORDER BY category" + ).fetchall()] + + empty_state = """ + + +BSN Community — Stimmen aus der Szene + + + + +

Bald geht's los!

Noch keine ver\u00f6ffentlichten Beitr\u00e4ge. Komm bald wieder — die Community f\u00fcllt diesen Ort mit spannenden Geschichten.

+""" + + if not rows: + return empty_state + + # Build filter bar HTML + filter_html = '
📍 Halle: ' + filter_html += f'Alle ' + for h in hallen: + active = " active" if halle_filter and h.startswith(halle_filter) else "" + url = f"/?halle={h}" + if cat_filter: + url += f"&cat={cat_filter}" + filter_html += f'{h} ' + if categories: + filter_html += '📂 Kategorie: ' + url = "/" + if halle_filter: + url += f"?halle={halle_filter}" + filter_html += f'Alle ' + for c in categories: + active = " active" if cat_filter == c else "" + url = f"/?cat={c}" + if halle_filter: + url += f"&halle={halle_filter}" + filter_html += f'{c} ' + filter_html += "
" + + cards = "" + for r in rows: + media_html = "" + media_first = "" + if r["media_path"]: + paths = r["media_path"].split("||") + # Parse publish flags + flags_str = r["media_publish_flags"] or "" + flags = flags_str.split(",") if flags_str else ["1"] * len(paths) + while len(flags) < len(paths): + flags.append("1") + mime_map = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", + ".mp4": "video/mp4", ".webm": "video/webm", + ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav", + } + for i, path in enumerate(paths): + # Skip media not marked for publishing + if flags[i] != "1": + continue + if not os.path.exists(path): + continue + _, ext = os.path.splitext(path) + mime = mime_map.get(ext.lower(), "application/octet-stream") + try: + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + src = "data:" + mime + ";base64," + b64 + if mime.startswith("image/"): + img_tag = ( + '' + '
' + 'Beitragsbild' + '
' + ) + if i == 0 and not media_first: + media_first = img_tag + else: + media_html += img_tag + elif mime.startswith("video/"): + thumb = generate_video_thumbnail(path) + if thumb: + with open(thumb, "rb") as f: + thumb_b64 = base64.b64encode(f.read()).decode() + thumb_src = "data:image/jpeg;base64," + thumb_b64 + else: + thumb_src = "" + vid_tag = ( + '' + '
' + + (f'Video-Vorschau' if thumb_src else '
🎬
') + + '
' + + '' + + '
' + ) + if i == 0 and not media_first: + media_first = vid_tag + else: + media_html += vid_tag + elif mime.startswith("audio/"): + aud_tag = '
🎤 Sprachnachricht anhören →
' + media_html += aud_tag + except Exception: + pass + + type_emoji = {"text": "\U0001f4ac", "image": "\U0001f5bc\ufe0f", "audio": "\U0001f3a4", "video": "\U0001f3ac", "document": "\U0001f4ce"}.get(r["type"], "\U0001f4cc") + name = (r["publish_name"] or r["sender_name"] or "Anonym").strip() + if not name or name.lower() in ("unknown", "anonym"): + name = "Anonym" + + summary_text = (r["summary"] or r["content"] or "") + # Truncate for card preview + preview = summary_text[:200] + ("..." if len(summary_text) > 200 else "") + date_str = (r["published_at"] or r["created_at"] or "") + # Format with relative + absolute time + time_html = "" + try: + from datetime import datetime + dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") + now = datetime.now() + diff = now - dt + if diff.days == 0: + if diff.seconds < 3600: + time_html = f'vor {diff.seconds // 60} Min.' + else: + time_html = f'heute {dt.strftime("%H:%M")} Uhr' + elif diff.days == 1: + time_html = f'gestern {dt.strftime("%H:%M")} Uhr' + elif diff.days < 7: + time_html = f'vor {diff.days} Tagen {dt.strftime("%H:%M")}' + else: + time_html = dt.strftime("%d.%m.%y %H:%M") + " Uhr" + date_str = dt.strftime("%d. %B %Y") + except: + pass + + cards += """
+ """ + media_first + """ + """ + (f'
📍 {r["halle"]}
' if r["halle"] else '') + """ + """ + (f'
🚫 AUSVERKAUFT: {r["spiel_titel"]}
' if r["category"] == "ausverkauft" and r["spiel_titel"] else '') + """ +
+ + """ + (f'
{time_html}
' if time_html else f'
{date_str}
') + """ +

""" + preview + """

+
+
""" + + return """ + + + +BSN Community — Stimmen aus der Szene + + + + + + + + +""" + filter_html + """ +
+""" + cards + """ +
+ + + +""" + + + + +@app.route('/api/query') +def api_query(): + '''Instant lookup: find submissions by hall, keyword, or sender.''' + db = get_db() + q = request.args.get('q', '').strip() + halle = request.args.get('halle', '').strip() + limit = min(int(request.args.get('limit', 5)), 20) + + query = 'SELECT id, halle, content, summary, type, sender_name, status FROM submissions WHERE status="published"' + params = [] + if q: + query += ' AND (content LIKE ? OR summary LIKE ?)' + params.extend([f'%{q}%', f'%{q}%']) + if halle: + query += ' AND halle = ?' + params.append(halle) + query += ' ORDER BY published_at DESC LIMIT ?' + params.append(limit) + + rows = db.execute(query, params).fetchall() + results = [] + for r in rows: + results.append({ + 'id': r['id'], + 'halle': r['halle'], + 'content': (r['summary'] or r['content'] or '')[:200], + 'type': r['type'], + 'sender_name': r['sender_name'] or 'Anonym', + 'status': r['status'], + }) + return jsonify({'results': results, 'count': len(results)}) + + +@app.route("/beitrag/") +def public_detail(sub_id: int): + """Public detail view — full media, text, and metadata.""" + import base64 + db = get_db() + row = db.execute( + "SELECT * FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL", + (sub_id,), + ).fetchone() + if not row: + return "

Nicht gefunden

← Zurück

", 404 + + # Build media HTML — videos are playable, images full-size + media_html = "" + if row["media_path"]: + paths = row["media_path"].split("||") + flags_str = row["media_publish_flags"] or "" + flags = flags_str.split(",") if flags_str else ["1"] * len(paths) + while len(flags) < len(paths): + flags.append("1") + mime_map = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", + ".mp4": "video/mp4", ".webm": "video/webm", + ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav", + } + for i, path in enumerate(paths): + if flags[i] != "1": + continue + if not os.path.exists(path): + continue + _, ext = os.path.splitext(path) + mime = mime_map.get(ext.lower(), "application/octet-stream") + try: + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + src = "data:" + mime + ";base64," + b64 + if mime.startswith("image/"): + media_html += f'Bild' + elif mime.startswith("video/"): + media_html += ( + f'' + ) + elif mime.startswith("audio/"): + media_html += f'' + except Exception: + pass + + type_emoji = {"text": "💬", "image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(row["type"], "📌") + name = (row["publish_name"] or row["sender_name"] or "Anonym").strip() + if not name or name.lower() in ("unknown", "anonym"): + name = "Anonym" + date_str = row["published_at"] or row["created_at"] or "" + try: + from datetime import datetime as dt + d = dt.strptime(date_str, "%Y-%m-%d %H:%M:%S") + date_str = d.strftime("%d. %B %Y") + except Exception: + pass + + content = (row["summary"] or row["content"] or "").strip() + + return f""" + + + +Beitrag — BSN Community + + + + + + +
+ ← Zurück zur Übersicht +
+ {"".join([f'
📍 {row["halle"]}
' if row["halle"] else '', f'
🚫 AUSVERKAUFT: {row["spiel_titel"]}
' if row["category"] == "ausverkauft" and row["spiel_titel"] else ''])} +
+ {type_emoji} + {name} + {date_str} +
+
{media_html}
+ {"".join([f'
{content}
' if content else ''])} +
+
+ + +""" + + +if __name__ == "__main__": + import threading + def keepalive(): + """Prevent process from being killed by inactivity timeout.""" + import time + while True: + time.sleep(60) + import sys + sys.stdout.write("[keepalive]\n") + sys.stdout.flush() + + threading.Thread(target=keepalive, daemon=True).start() + + print(f"🚀 BSN Intake starting on http://127.0.0.1:{PORT}") + print(f" DB: {DB_PATH}") + print(f" Media: {MEDIA_DIR}") + print(f" Meta API: {'✅ configured' if META_TOKEN else '❌ no token'}") + print(f" Admin: http://127.0.0.1:{PORT}/admin") + print(f" Webhook: http://127.0.0.1:{PORT}/whatsapp/webhook") + app.run(host="127.0.0.1", port=PORT, debug=True) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..241c06b --- /dev/null +++ b/init_db.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Initialize the BSN Intake SQLite database with schema + sample data.""" + +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "bsn_intake.db") + +SCHEMA = """ +-- Incoming submissions from all channels +CREATE TABLE IF NOT EXISTS submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, -- 'whatsapp', 'telegram', 'web_form' + sender_id TEXT NOT NULL, -- phone number, telegram ID, email + type TEXT NOT NULL, -- 'text', 'image', 'voice', 'video' + content TEXT, -- transcribed text or message body + media_path TEXT, -- local path to saved media file + status TEXT DEFAULT 'new', -- 'new', 'processing', 'done', 'flagged' + category TEXT, -- Hermes-classified category + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Knowledge base for the query chatbot +CREATE TABLE IF NOT EXISTS knowledge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, -- lookup key (e.g. 'autor_uwe_rosenberg') + question_pattern TEXT, -- "Where is Uwe Rosenberg?" + answer TEXT NOT NULL, -- "Hall 3, Booth B12, 10am-6pm" + category TEXT, + tags TEXT, -- comma-separated + valid_from DATE, + valid_until DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for fast lookups +CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status); +CREATE INDEX IF NOT EXISTS idx_submissions_channel ON submissions(channel); +CREATE INDEX IF NOT EXISTS idx_submissions_category ON submissions(category); +CREATE INDEX IF NOT EXISTS idx_knowledge_key ON knowledge(key); +CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge(category); +""" + +SAMPLE_SUBMISSIONS = [ + ("whatsapp", "+491234567890", "text", "Wo ist der Stand von Feuerland? Hallo?", "new", None), + ("whatsapp", "+491234567891", "text", "Catan ist ausverkauft in Halle 5!", "new", "ausverkauft"), + ("telegram", "8520780873", "text", "Habe eben gesehen: Kosmos Halle 3, Stand A42 hat noch Restbestände von Die Crew", "new", "restbestände"), + ("web_form", "besucher@example.com", "text", "Warum gibt es keine barrierefreien Zugänge in Halle 8?", "flagged", "kritik"), +] + +SAMPLE_KNOWLEDGE = [ + ("feuerland_stand", "Wo ist Feuerland?", "Feuerland Spiele findest du in Halle 3, Stand C17. Geöffnet von 10–18 Uhr.", "verlage", "feuerland, stand, messe", "2026-10-01", "2026-10-04"), + ("catan_verfuegbar", "Ist Catan noch verfügbar?", "Catan war Stand 12.06.2026 in Halle 5 ausverkauft. Kosmos meldet Nachschub für Morgen früh.", "verlage", "catan, kosmos, ausverkauft", "2026-10-01", "2026-10-04"), + ("oeffnungszeiten", "Wann öffnet die Messe?", "Die Spiel Essen 2026 öffnet Donnerstag bis Samstag von 10–18 Uhr, Sonntag von 10–17 Uhr.", "messe", "öffnungszeiten, messe, essen", None, None), +] + + +def init(): + conn = sqlite3.connect(DB_PATH) + conn.executescript(SCHEMA) + + # Check if data already exists + count = conn.execute("SELECT COUNT(*) FROM submissions").fetchone()[0] + if count == 0: + conn.executemany( + "INSERT INTO submissions (channel, sender_id, type, content, status, category) VALUES (?,?,?,?,?,?)", + SAMPLE_SUBMISSIONS, + ) + print(f"Inserted {len(SAMPLE_SUBMISSIONS)} sample submissions") + + count = conn.execute("SELECT COUNT(*) FROM knowledge").fetchone()[0] + if count == 0: + conn.executemany( + "INSERT INTO knowledge (key, question_pattern, answer, category, tags, valid_from, valid_until) VALUES (?,?,?,?,?,?,?)", + SAMPLE_KNOWLEDGE, + ) + print(f"Inserted {len(SAMPLE_KNOWLEDGE)} sample knowledge entries") + + conn.commit() + conn.close() + print(f"Database initialized: {DB_PATH}") + + +if __name__ == "__main__": + init() diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..6cf60a3 --- /dev/null +++ b/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Start the BSN Chatbot Intake server (uses venv Python for Flask + requests) +set -a +source "$(dirname "$0")/.env" +set +a + +echo "META_TOKEN set: $([ -n "$META_TOKEN" ] && echo 'YES' || echo 'NO')" +echo "PHONE_NUMBER_ID: $META_PHONE_NUMBER_ID" +echo "" + +exec /home/hermes/venv/bin/python3 "$(dirname "$0")/app.py" diff --git a/start-tunnel.sh b/start-tunnel.sh new file mode 100755 index 0000000..5a46d67 --- /dev/null +++ b/start-tunnel.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Start permanent Cloudflare Tunnel for BSN Chatbot +# Usage: bash start-tunnel.sh + +set -e + +TOKEN="$1" +if [ -z "$TOKEN" ]; then + echo "Usage: bash start-tunnel.sh " + exit 1 +fi + +# Save token +echo "$TOKEN" > /home/hermes/workspace/bsn-chatbot/.cloudflare_token +chmod 600 /home/hermes/workspace/bsn-chatbot/.cloudflare_token +echo "Token saved ($(wc -c < /home/hermes/workspace/bsn-chatbot/.cloudflare_token) chars)" + +# Kill any existing cloudflared +pkill -9 -f cloudflared 2>/dev/null || true +sleep 1 + +# Start permanent tunnel +echo "Starting tunnel..." +exec /home/hermes/.local/bin/cloudflared tunnel run --token "$TOKEN" \ No newline at end of file diff --git a/supervisor.py b/supervisor.py new file mode 100644 index 0000000..311cc6b --- /dev/null +++ b/supervisor.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +BSN Intake Supervisor — starts Flask app + Cloudflare tunnel. +Keeps both alive, restarts on crash, prints the public URL. +""" + +import subprocess +import time +import os +import sys +import re + +BASE_DIR = "/home/hermes/workspace/bsn-chatbot" +VENV_PYTHON = "/home/hermes/venv/bin/python3" +CLOUDFLARED = "/home/hermes/.local/bin/cloudflared" + +# Load .env +env = os.environ.copy() +env_file = os.path.join(BASE_DIR, ".env") +if os.path.exists(env_file): + with open(env_file) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + env[key] = val + +def start_flask(): + """Start the Flask app as a subprocess.""" + proc = subprocess.Popen( + [VENV_PYTHON, os.path.join(BASE_DIR, "app.py")], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return proc + +def start_cloudflared(): + """Start the Cloudflare tunnel.""" + proc = subprocess.Popen( + [CLOUDFLARED, "tunnel", "--url", "http://127.0.0.1:5002"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return proc + + +def main(): + LOG = open("/tmp/bsn_supervisor.log", "w") + def log(msg): + LOG.write(msg + "\n") + LOG.flush() + + log("🚀 BSN Intake Supervisor starting...") + + # Start Flask + flask = start_flask() + log("[flask] started") + time.sleep(2) + + # Verify Flask is up + import urllib.request + try: + urllib.request.urlopen("http://127.0.0.1:5002/health", timeout=5) + log("[flask] health check: OK") + except Exception as e: + log(f"[flask] health check FAILED: {e}") + + # Start Cloudflared + cf = start_cloudflared() + log("[cloudflared] started") + + # Wait for tunnel URL to appear in output + tunnel_url = None + deadline = time.time() + 20 + buffer = "" + while time.time() < deadline: + line = cf.stdout.readline() if cf.stdout else "" + if line: + buffer += line + m = re.search(r"(https://[a-z-]+\.trycloudflare\.com)", buffer) + if m: + tunnel_url = m.group(1) + break + if flask.poll() is not None: + log(f"[flask] CRASHED! exit={flask.returncode}") + break + if cf.poll() is not None: + log(f"[cloudflared] CRASHED! exit={cf.returncode}") + break + + if tunnel_url: + log(f"\n✅ TUNNEL: {tunnel_url}") + log(f" Webhook: {tunnel_url}/whatsapp/webhook") + log(f" Admin: {tunnel_url}/admin") + else: + log("❌ Failed to get tunnel URL") + + log("\n[supervisor] Running. Press Ctrl+C to stop.\n") + + # Keep running, restart children if they die + try: + while True: + if flask.poll() is not None: + log("[flask] died, restarting...") + flask = start_flask() + if cf.poll() is not None: + log("[cloudflared] died, restarting...") + cf = start_cloudflared() + time.sleep(5) + except KeyboardInterrupt: + log("\n[supervisor] Shutting down...") + flask.terminate() + cf.terminate() + flask.wait() + cf.wait() + + +if __name__ == "__main__": + main()