#!/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

{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
✍️ 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": # Partial update — only update fields that are provided fields = [] params = [] if summary: fields.append("summary=?") params.append(summary) if publish_name: fields.append("publish_name=?") params.append(publish_name) if halle: fields.append("halle=?") params.append(halle) if spiel_titel: fields.append("spiel_titel=?") params.append(spiel_titel) # Clear fields explicitly set to empty if request.form.get("summary") is not None and not summary: fields.append("summary=NULL") if request.form.get("publish_name") is not None and not publish_name: fields.append("publish_name=NULL") if request.form.get("halle") is not None and not halle: fields.append("halle=NULL") if request.form.get("spiel_titel") is not None and not spiel_titel: fields.append("spiel_titel=NULL") if fields: params.append(sub_id) db.execute(f"UPDATE submissions SET {', '.join(fields)} WHERE id=?", params) 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 — group by hall number (H1-H7) hallen = [] hall_rows = db.execute( "SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL AND status='published' ORDER BY halle" ).fetchall() seen = set() for r in hall_rows: hnum = r[0][:2] if r[0] else "" if hnum and hnum[0] == 'H' and hnum[1:].isdigit() and hnum not in seen: seen.add(hnum) hallen.append(hnum) 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 — hallen as dropdown filter_html = '
📍 Halle: ' filter_html += '
' if cat_filter: filter_html += f'' filter_html += '
' if categories: filter_html += '📂 Kategorie: ' filter_html += '
' if halle_filter: filter_html += f'' filter_html += '
' if halle_filter or cat_filter: filter_html += '✕ Filter zurücksetzen' 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 text_only_class = " text-only" if not media_first else "" cards += f"""
""" + 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 + """
💬 BSN Assistant
Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!
📨 Etwas einreichen
🎲 Schick uns, was dich bewegt! Texte, Fotos, Videos oder Sprachnachrichten — alles rund um Brettspiele und die SPIEL Essen. Wir schauen uns alles an und veröffentlichen die spannendsten Beiträge.
Nur Text oder nur eine Datei reicht völlig.
""" @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('/api/chat', methods=['POST']) def api_chat(): """Chat agent with read-only access to published submissions.""" db = get_db() data = request.get_json(force=True, silent=True) or {} user_message = (data.get('message') or '').strip() if not user_message: return jsonify({'reply': 'Leere Nachricht. Was möchtest du wissen?'}) # Build context from published submissions rows = db.execute( "SELECT id, halle, content, summary, type, sender_name, category, " "spiel_titel, published_at " "FROM submissions WHERE status='published' AND deleted_at IS NULL " "ORDER BY published_at DESC LIMIT 60" ).fetchall() if not rows: return jsonify({'reply': 'Noch keine Beiträge veröffentlicht. Schau später wieder vorbei!'}) # Build compact context string context_parts = [] for i, r in enumerate(rows, 1): text = (r['summary'] or r['content'] or '').strip()[:400] halle = r['halle'] or '' spiel = r['spiel_titel'] or '' cat = r['category'] or '' name = (r['sender_name'] or 'Anonym').strip() context_parts.append( f"[{i}] {text}" + (f" | 📍{halle}" if halle else '') + (f" | 🎲{spiel}" if spiel else '') + (f" | #{cat}" if cat else '') + (f" | von {name}" if name else '') ) context = '\n'.join(context_parts) # System prompt system = ( "Du bist der BSN Community Assistant von brettspiel-news.de. " "Du hilfst Besuchern, Informationen über die Brettspiel-Community auf der SPIEL Essen zu finden.\n\n" "WICHTIGE REGELN:\n" "- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n" "- Wenn die Antwort nicht im Kontext steht, sage ehrlich: 'Dazu habe ich leider keine Informationen.'\n" "- Erfinde NIEMALS Informationen, Namen, Stände oder Spieletitel.\n" "- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n" "- Halte Antworten kurz und präzise (2-4 Sätze).\n" "- Bei Standortfragen nenne immer die Hallen-Nummer.\n" "- Der Kontext enthält nummerierte Beiträge [1], [2] usw. — du kannst darauf verweisen.\n\n" f"KONTEXT (veröffentlichte Community-Beiträge):\n{context[:8000]}" ) # Read API key config_path = os.path.expanduser("~/.hermes/config.yaml") api_key = "" try: with open(config_path) as f: import yaml as _yaml cfg = _yaml.safe_load(f) api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "") except Exception: pass if not api_key: return jsonify({'reply': 'Chat-Assistent ist aktuell nicht verfügbar (API-Key fehlt).'}) # Build messages with conversation history messages = [{"role": "system", "content": system}] history = data.get('history') or [] if isinstance(history, list): for h in history[-10:]: # last 10 messages max if isinstance(h, dict) and 'role' in h and 'content' in h: messages.append({"role": h['role'], "content": h['content']}) messages.append({"role": "user", "content": user_message}) try: r = requests.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": messages, "max_tokens": 400, "temperature": 0.4}, timeout=15, ) if r.status_code != 200: return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) reply = r.json()["choices"][0]["message"]["content"].strip() except Exception: return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'}) return jsonify({'reply': reply}) @app.route('/api/submit', methods=['POST']) def api_submit(): """Public submission endpoint — text + optional media upload.""" db = get_db() name = (request.form.get('name') or '').strip()[:100] text = (request.form.get('text') or '').strip()[:5000] halle = (request.form.get('halle') or '').strip()[:20] spiel_titel = (request.form.get('spiel_titel') or '').strip()[:200] # Require at least text or a file file = request.files.get('media') if not text and (not file or not file.filename): return jsonify({'error': 'Bitte Text oder eine Datei (Foto/Video/Audio) angeben.'}), 400 media_path = None msg_type = 'text' if file and file.filename: # Determine type from extension ext = os.path.splitext(file.filename)[1].lower() mime_map = { '.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.webp': 'image', '.gif': 'image', '.mp4': 'video', '.webm': 'video', '.ogg': 'audio', '.mp3': 'audio', '.wav': 'audio', } msg_type = mime_map.get(ext, 'document') # Add type prefix to content prefix = { 'image': '\n[🖼️ Bild]', 'video': '\n[🎬 Video]', 'audio': '\n[🎤 Sprachnachricht]', 'document': '\n[📎 Dokument]', }.get(msg_type, f'\n[{msg_type}]') if text: text = text + prefix else: text = prefix.strip() # Save file ts = datetime.now().strftime('%Y%m%d_%H%M%S') safe_name = f"web_{ts}{ext}" save_path = MEDIA_DIR / safe_name try: file.save(str(save_path)) media_path = str(save_path) except Exception: return jsonify({'error': 'Datei konnte nicht gespeichert werden.'}), 500 # Validate halle halle_valid = None if halle: import re if re.match(r'^H[1-7]\s*[A-Z]\d{1,4}$', halle, re.IGNORECASE): halle_valid = halle.upper() sender_id = 'web_' + (name.replace(' ', '_') if name else 'anonym') sender_name = name or None db.execute( """INSERT INTO submissions (channel, sender_id, sender_name, type, content, media_path, status, halle, spiel_titel) VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?)""", ('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, spiel_titel or None), ) db.commit() return jsonify({'ok': True, 'message': 'Danke für deine Einreichung! Wir schauen sie uns an. 🎲'}) @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)