""" + preview + """
| ID | Sender | Typ | 📍 Halle | 🎯 Spiel | Inhalt | Status | Zeit |
|---|
#!/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 re 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'
| ID | Sender | Typ | 📍 Halle | 🎯 Spiel | Inhalt | Status | Zeit |
|---|
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?"}
}]
}
}]
}]
}'
"""
@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/📁 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'' except Exception as e: media_html += f'Fehler: {e}
' # SVG type icons (16x16, BSN blue) _svg_icons = { "text": '', "image": '', "audio": '', "video": '', "document": '', } type_emoji = _svg_icons.get(row["type"], _svg_icons["document"]) return f"""{row['content'] or '(leer)'}Nur Medien mit Haken erscheinen im Frontend.
Löscht den Eintrag und alle Mediendateien unwiderruflich.
Noch keine ver\u00f6ffentlichten Beitr\u00e4ge. Komm bald wieder — die Community f\u00fcllt diesen Ort mit spannenden Geschichten.
""" + preview + """
""" + preview + """