diff --git a/fuenfvoracht/.env b/fuenfvoracht/.env new file mode 100644 index 000000000..53351a87f --- /dev/null +++ b/fuenfvoracht/.env @@ -0,0 +1,9 @@ +TELEGRAM_BOT_TOKEN=8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc +TELEGRAM_CHANNEL_ID=-1001285446620 +ADMIN_CHAT_ID=674951792 +REVIEW_CHAT_IDS=674951792,1329146910 +OPENROUTER_API_KEY=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 +AI_MODEL=openai/gpt-4o-mini +POST_TIME=19:55 +TIMEZONE=Europe/Berlin +DB_PATH=/data/fuenfvoracht.db diff --git a/fuenfvoracht/.gitignore b/fuenfvoracht/.gitignore new file mode 100644 index 000000000..9460c99c4 --- /dev/null +++ b/fuenfvoracht/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/fuenfvoracht/STATE.md b/fuenfvoracht/STATE.md new file mode 100644 index 000000000..8d02768d7 --- /dev/null +++ b/fuenfvoracht/STATE.md @@ -0,0 +1,189 @@ +# STATE: FünfVorAcht +**Stand: 26.02.2026** + +--- + +## Projektübersicht + +**Zweck:** KI-gestützter Telegram-Kanal-Poster für die tägliche Reihe "Fünf vor Acht" +**Posting-Zeit:** 19:55 Uhr (Europe/Berlin) — pro Artikel individuell konfigurierbar +**Kanal:** Privater Kanal (`-1001285446620`) +**Status:** ✅ Produktiv seit 24.02.2026 + +--- + +## Aktiver Zustand + +### Container-Landschaft + +| CT | Dienst | Status | Tailscale | +|----|--------|--------|-----------| +| 112 | FünfVorAcht (Bot + Dashboard) | ✅ Läuft | 100.73.171.62 | + +--- + +## Architektur + +### Stack (CT 112 auf pve-hetzner) +- **Sprache:** Python 3.11 +- **Bot-Framework:** python-telegram-bot +- **KI:** OpenRouter API (GPT-4o-mini, konfigurierbar) +- **Scheduler:** APScheduler (jede Minute für Posting, alle 5 Min für Notify-Check) +- **DB:** SQLite mit WAL-Modus +- **Dashboard:** Flask + Tailwind CSS (self-hosted, kein CDN) +- **Deployment:** Docker Compose (2 Container: bot + web) +- **Logging:** JSON-Lines nach `/logs/fuenfvoracht.log` + +### Workflow + +``` +Redakteur gibt Quelle/Thema ein + ↓ +KI generiert Artikel (OpenRouter) + ↓ +Redigieren im Dashboard + Telegram-Vorschau + ↓ +Einplanen: Datum + 15-Min-Zeitslot + Bot-Benachrichtigungszeit + ↓ +Scheduler schickt Review an alle Redakteure (notify_at) + ↓ +[✅ Freigeben] [✏️ Bearbeiten] + ↓ +APScheduler postet automatisch zum Zeitslot + ↓ +Bestätigung + Markenzeichen automatisch angehängt +``` + +--- + +## Pfade & Konfiguration + +| Was | Pfad | +|-----|------| +| App-Verzeichnis | /opt/fuenfvoracht/ | +| Docker Compose | /opt/fuenfvoracht/docker-compose.yml | +| Datenbank | /opt/fuenfvoracht/data/fuenfvoracht.db | +| Logs (JSON) | /opt/fuenfvoracht/logs/fuenfvoracht.log | +| Prompts | In SQLite DB (Tabelle: prompts) | +| Tailwind CSS | Im Docker-Image gebaut (/app/static/tailwind.min.css) | + +--- + +## Datenbank-Schema + +### Tabellen + +| Tabelle | Zweck | +|---------|-------| +| articles | Artikel-Queue mit `date + post_time` als Unique-Key | +| article_versions | Alle Versionen bei Neu-Generierung | +| post_history | Posting-Log mit Channel-Message-IDs | +| prompts | KI-Prompt-Bibliothek (editierbar) | +| sources_favorites | Gespeicherte Quellen-Favoriten | +| tags | Themen-Kategorien | +| article_tags | Artikel ↔ Tags (n:m) | +| channels | Kanal-Konfiguration (Zeit, Timezone) | +| locations | Aufenthaltsorte mit Reminder-Zeiten | +| settings | Schlüssel-Wert-Paare (z.B. user_location_id) | +| reviewers | Redakteure (chat_id, name, active) | + +### Article-Status-Lifecycle +``` +draft → scheduled → sent_to_bot → approved → posted + ↘ rejected + ↘ skipped + ↘ pending_review +``` + +### Zeitslot-System +- `UNIQUE(date, post_time)` — Konflikte technisch ausgeschlossen +- `post_time` im 15-Minuten-Raster (06:00, 06:15, … 23:45) +- `notify_at` — UTC-Timestamp wann der Review-Bot benachrichtigt +- `scheduled_at` — wann der Artikel eingeplant wurde + +--- + +## Telegram-Setup + +| Was | Wert | +|-----|------| +| Review-Bot Token | 8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc | +| Review-Bot Name | @Diendemleben_bot | +| Kanal-ID | -1001285446620 | +| Redakteur 1 | Chat-ID 674951792 | +| Redakteur 2 | Chat-ID 1329146910 | + +--- + +## Dashboard-Features + +- **Studio:** Artikel generieren, redigieren, Telegram-Vorschau in Echtzeit +- **Einplan-Panel:** Datum + 15-Min-Zeitslot + Bot-Benachrichtigungszeit +- **Redaktionsplan:** Nächste 7 Tage, mehrere Slots pro Tag, Umplanen + Löschen direkt im Board +- **Monatskalender:** Status-Dots pro Tag +- **Prompt-Editor:** Bearbeiten + Test mit Telegram-Preview +- **History:** Alle Posts der letzten 30 Tage +- **Quellen-Favoriten:** Häufig genutzte Quellen +- **Redakteure-Verwaltung:** Hinzufügen/Entfernen per Chat-ID +- **Aufenthaltsort-Schalter:** Reminder-Zeiten automatisch auf MEZ umgerechnet +- **Anleitung (/hilfe):** 6 Aufgabenbereiche mit Klickpfaden + +--- + +## Bot-Features + +| Feature | Details | +|---------|---------| +| `/start` | Übersicht & Befehle (alle Redakteure) | +| `/heute` | Alle Slots des heutigen Tages | +| `/queue` | Nächste 3 Tage mit Slots | +| `/skip` | Hauptslot heute überspringen | +| Inline-Review | ✅ Freigeben / ✏️ Bearbeiten | +| Morgen-Briefing | 10:00 MEZ: Tagesplan + Ausblick 3 Tage | +| Nachmittags-Reminder | 18:00 MEZ: Warnung wenn nicht freigegeben | +| Fehler-Alarm | Sofort bei Posting-Fehler: Ursache + Dashboard-Link | +| Willkommensnachricht | Automatisch bei neuem Redakteur | + +--- + +## Markenzeichen (automatisch) + +Wird unter **jeden** Beitrag angehängt (Duplikat-Schutz aktiv): +``` +Wir schützen die Zukunft unserer Kinder und das Leben❤️ + +Pax et Lux Terranaut01 https://t.me/DieneDemLeben + +Unterstützt die Menschen, die für Uns einstehen❗️ +``` + +--- + +## Logging + +Strukturiertes JSON-Logging nach `/logs/fuenfvoracht.log`: +```json +{"ts": "2026-02-26T14:46:19Z", "level": "INFO", "event": "article_posted", "date": "2026-02-26", "post_time": "19:55", ...} +``` + +Events: `article_generated`, `article_saved`, `article_scheduled`, `article_sent_to_bot`, `article_approved`, `article_posted`, `article_skipped`, `posting_failed`, `reviewer_added`, `reviewer_removed`, `slot_conflict`, `bot_started`, `morning_briefing_sent` + +--- + +## Routing + +- Dashboard: `https://fuenfvoracht.orbitalo.net` (Cloudflare Tunnel) +- Lokal: `http://100.73.171.62:8080` +- Login: Holgerhh / ddlhh +- Kein öffentlicher Zugriff außer via Cloudflare Tunnel + +--- + +## Offene Punkte / TODOs + +- [ ] 15-Min-Einplan-Panel in Dashboard-UI integrieren (API vorhanden) +- [ ] Board: Umplanen/Löschen Buttons in index.html +- [ ] Redakteure-Verwaltung in settings.html +- [ ] Kanal-ID in Settings-UI editierbar +- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop) +- [ ] Letzter-Post Zeitstempel im Dashboard anzeigen diff --git a/fuenfvoracht/docker-compose.yml b/fuenfvoracht/docker-compose.yml new file mode 100644 index 000000000..36c0e9e93 --- /dev/null +++ b/fuenfvoracht/docker-compose.yml @@ -0,0 +1,36 @@ +services: + bot: + build: + context: ./src + dockerfile: Dockerfile.bot + container_name: fuenfvoracht-bot + restart: unless-stopped + env_file: .env + environment: + - DB_PATH=/data/fuenfvoracht.db + volumes: + - ./data:/data + - ./logs:/logs + networks: + - fuenfvoracht + + web: + build: + context: ./src + dockerfile: Dockerfile.web + container_name: fuenfvoracht-web + restart: unless-stopped + env_file: .env + environment: + - DB_PATH=/data/fuenfvoracht.db + volumes: + - ./data:/data + - ./logs:/logs + ports: + - "8080:8080" + networks: + - fuenfvoracht + +networks: + fuenfvoracht: + driver: bridge diff --git a/fuenfvoracht/src/Dockerfile.bot b/fuenfvoracht/src/Dockerfile.bot new file mode 100644 index 000000000..64b56391a --- /dev/null +++ b/fuenfvoracht/src/Dockerfile.bot @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements-bot.txt . +RUN pip install --no-cache-dir -r requirements-bot.txt +COPY database.py openrouter.py bot.py logger.py ./ +CMD ["python", "bot.py"] diff --git a/fuenfvoracht/src/Dockerfile.web b/fuenfvoracht/src/Dockerfile.web new file mode 100644 index 000000000..4925343f3 --- /dev/null +++ b/fuenfvoracht/src/Dockerfile.web @@ -0,0 +1,11 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY requirements-web.txt . +RUN pip install --no-cache-dir -r requirements-web.txt +COPY database.py openrouter.py app.py logger.py ./ +COPY templates/ templates/ +RUN mkdir -p /app/static && \ + curl -sL https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css \ + -o /app/static/tailwind.min.css +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"] diff --git a/fuenfvoracht/src/app.py b/fuenfvoracht/src/app.py new file mode 100644 index 000000000..dfdc11c1e --- /dev/null +++ b/fuenfvoracht/src/app.py @@ -0,0 +1,531 @@ +from flask import Flask, render_template, request, jsonify, redirect, url_for, Response +from functools import wraps +from datetime import datetime, date, timedelta +import os +import asyncio +import logging +import requests as req_lib + +import database as db +import openrouter +import logger as flog + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +TZ_NAME = os.environ.get('TIMEZONE', 'Europe/Berlin') +BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') +POST_TIME = os.environ.get('POST_TIME', '19:55') + +AUTH_USER = os.environ.get('AUTH_USER', 'Holgerhh') +AUTH_PASS = os.environ.get('AUTH_PASS', 'ddlhh') + +BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben" +BRAND_SIGNATURE = ( + "Wir schützen die Zukunft unserer Kinder und das Leben❤️\n\n" + "Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n" + "Unterstützt die Menschen, die für Uns einstehen❗️" +) + + +def check_auth(username, password): + return username == AUTH_USER and password == AUTH_PASS + + +def authenticate(): + return Response( + 'Zugang verweigert.', 401, + {'WWW-Authenticate': 'Basic realm="FünfVorAcht"'}) + + +@app.before_request +def before_request_auth(): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + + +def today_str(): + import pytz + return datetime.now(pytz.timezone(TZ_NAME)).strftime('%Y-%m-%d') + + +def today_display(): + import pytz + return datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y') + + +def week_range(): + today = date.today() + start = today - timedelta(days=today.weekday()) + return [(start + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)] + + +def planning_days(count=7): + import pytz + tz = pytz.timezone(TZ_NAME) + t = datetime.now(tz).date() + return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)] + + +def with_branding(content: str) -> str: + text = (content or "").rstrip() + if BRAND_MARKER in text: + return text + return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE + + +def send_telegram_message(chat_id, text, reply_markup=None): + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} + if reply_markup: + import json + payload["reply_markup"] = json.dumps(reply_markup) + try: + r = req_lib.post(url, json=payload, timeout=10) + return r.json() + except Exception as e: + logger.error("Telegram send fehlgeschlagen: %s", e) + return None + + +def notify_all_reviewers(text, reply_markup=None): + results = [] + for chat_id in db.get_reviewer_chat_ids(): + result = send_telegram_message(chat_id, text, reply_markup) + results.append({'chat_id': chat_id, 'ok': bool(result and result.get('ok'))}) + return results + + +# ── Main Dashboard ──────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + today = today_str() + articles_today = db.get_articles_by_date(today) + article_today = articles_today[0] if articles_today else None + week_days = week_range() + week_articles_raw = db.get_week_articles(week_days[0], week_days[-1]) + # Mehrere Artikel pro Tag: dict date → list + week_articles = {} + for a in week_articles_raw: + week_articles.setdefault(a['date'], []).append(a) + + recent = db.get_recent_articles(10) + stats = db.get_monthly_stats() + channel = db.get_channel() + prompts = db.get_prompts() + tags = db.get_tags() + favorites = db.get_favorites() + locations = db.get_locations() + current_location = db.get_current_location() + reviewers = db.get_reviewers() + last_posted = db.get_last_posted() + + plan_days = planning_days(7) + plan_raw = db.get_week_articles(plan_days[0], plan_days[-1]) + plan_articles = {} + for a in plan_raw: + plan_articles.setdefault(a['date'], []).append(a) + + month_start = date.today().replace(day=1).strftime('%Y-%m-%d') + month_end = (date.today().replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1) + month_articles = {} + for a in db.get_week_articles(month_start, month_end.strftime('%Y-%m-%d')): + month_articles.setdefault(a['date'], []).append(a['status']) + + return render_template('index.html', + today=today, + article_today=article_today, + articles_today=articles_today, + week_days=week_days, + week_articles=week_articles, + plan_days=plan_days, + plan_articles=plan_articles, + month_articles=month_articles, + recent=recent, + stats=stats, + channel=channel, + post_time=POST_TIME, + prompts=prompts, + tags=tags, + favorites=favorites, + locations=locations, + current_location=current_location, + reviewers=reviewers, + last_posted=last_posted) + + +# ── History ─────────────────────────────────────────────────────────────────── + +@app.route('/history') +def history(): + articles = db.get_recent_articles(30) + return render_template('history.html', articles=articles) + + +# ── Prompts ─────────────────────────────────────────────────────────────────── + +@app.route('/prompts') +def prompts(): + all_prompts = db.get_prompts() + return render_template('prompts.html', prompts=all_prompts) + + +@app.route('/prompts/save', methods=['POST']) +def save_prompt(): + pid = request.form.get('id') + name = request.form.get('name', '').strip() + system_prompt = request.form.get('system_prompt', '').strip() + if pid: + db.save_prompt(int(pid), name, system_prompt) + else: + db.create_prompt(name, system_prompt) + return redirect(url_for('prompts')) + + +@app.route('/prompts/default/', methods=['POST']) +def set_default_prompt(pid): + db.set_default_prompt(pid) + return redirect(url_for('prompts')) + + +@app.route('/prompts/delete/', methods=['POST']) +def delete_prompt(pid): + db.delete_prompt(pid) + return redirect(url_for('prompts')) + + +@app.route('/prompts/test', methods=['POST']) +def test_prompt(): + data = request.get_json() + system_prompt = data.get('system_prompt', '') + source = data.get('source', 'https://tagesschau.de') + tag = data.get('tag', 'Politik') + prompt_id = data.get('prompt_id') + import pytz + date_display = datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y') + try: + result = asyncio.run(openrouter.generate_article(source, system_prompt, date_display, tag)) + if prompt_id: + db.save_prompt_test_result(int(prompt_id), result) + return jsonify({'success': True, 'result': result}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +# ── Hilfe ───────────────────────────────────────────────────────────────────── + +@app.route('/hilfe') +def hilfe(): + return render_template('hilfe.html') + + +# ── Settings ────────────────────────────────────────────────────────────────── + +@app.route('/settings') +def settings(): + channel = db.get_channel() + favorites = db.get_favorites() + tags = db.get_tags() + reviewers = db.get_reviewers(active_only=False) + return render_template('settings.html', channel=channel, + favorites=favorites, tags=tags, + reviewers=reviewers) + + +@app.route('/settings/channel', methods=['POST']) +def save_channel(): + telegram_id = request.form.get('telegram_id', '').strip() + post_time = request.form.get('post_time', '19:55').strip() + db.update_channel(telegram_id, post_time) + return redirect(url_for('settings')) + + +@app.route('/settings/favorite/add', methods=['POST']) +def add_favorite(): + label = request.form.get('label', '').strip() + url = request.form.get('url', '').strip() + if label and url: + db.add_favorite(label, url) + return redirect(url_for('settings')) + + +# ── Reviewer API ────────────────────────────────────────────────────────────── + +@app.route('/api/reviewers', methods=['GET']) +def api_reviewers(): + return jsonify(db.get_reviewers(active_only=False)) + + +@app.route('/api/reviewers/add', methods=['POST']) +def api_add_reviewer(): + data = request.get_json() + chat_id = data.get('chat_id') + name = data.get('name', '').strip() + if not chat_id or not name: + return jsonify({'success': False, 'error': 'chat_id und name erforderlich'}) + try: + chat_id = int(chat_id) + except ValueError: + return jsonify({'success': False, 'error': 'Ungültige Chat-ID'}) + added = db.add_reviewer(chat_id, name) + if not added: + return jsonify({'success': False, 'error': 'Chat-ID bereits vorhanden'}) + # Willkommensnachricht + welcome = ( + f"👋 Willkommen bei FünfVorAcht!\n\n" + f"Du wurdest als Redakteur hinzugefügt.\n" + f"Ab jetzt erhältst du Reviews, Reminder und Status-Meldungen.\n\n" + f"/start für eine Übersicht aller Befehle." + ) + send_telegram_message(chat_id, welcome) + return jsonify({'success': True}) + + +@app.route('/api/reviewers/remove', methods=['POST']) +def api_remove_reviewer(): + data = request.get_json() + chat_id = data.get('chat_id') + if not chat_id: + return jsonify({'success': False, 'error': 'chat_id erforderlich'}) + db.remove_reviewer(int(chat_id)) + return jsonify({'success': True}) + + +# ── API Endpoints ───────────────────────────────────────────────────────────── + +@app.route('/api/generate', methods=['POST']) +def api_generate(): + data = request.get_json() + source = data.get('source', '').strip() + tag = data.get('tag', 'allgemein') + prompt_id = data.get('prompt_id') + date_str = data.get('date', today_str()) + post_time = data.get('post_time', POST_TIME) + if not source: + return jsonify({'success': False, 'error': 'Keine Quelle angegeben'}) + prompt = None + if prompt_id: + all_prompts = db.get_prompts() + prompt = next((p for p in all_prompts if str(p['id']) == str(prompt_id)), None) + if not prompt: + prompt = db.get_default_prompt() + if not prompt: + return jsonify({'success': False, 'error': 'Kein Prompt konfiguriert'}) + try: + content = asyncio.run( + openrouter.generate_article(source, prompt['system_prompt'], today_display(), tag) + ) + existing = db.get_article_by_date(date_str, post_time) + if existing: + db.update_article_content(date_str, content, new_version=True, post_time=post_time) + conn = db.get_conn() + conn.execute( + "UPDATE articles SET source_input=?, tag=?, status='draft' WHERE date=? AND post_time=?", + (source, tag, date_str, post_time) + ) + conn.commit() + conn.close() + else: + db.create_article(date_str, source, content, prompt['id'], tag, post_time) + flog.article_generated(date_str, source, 1, tag) + return jsonify({'success': True, 'content': content}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@app.route('/api/article//save', methods=['POST']) +def api_save(date_str): + data = request.get_json() + content = data.get('content', '').strip() + post_time = data.get('post_time', POST_TIME) + if not content: + return jsonify({'success': False, 'error': 'Kein Inhalt'}) + existing = db.get_article_by_date(date_str, post_time) + if existing: + db.update_article_content(date_str, content, post_time=post_time) + flog.article_saved(date_str, post_time) + return jsonify({'success': True}) + + +@app.route('/api/article//schedule', methods=['POST']) +def api_schedule(date_str): + """Artikel einplanen: post_time + notify_at setzen.""" + data = request.get_json() + post_time = data.get('post_time', POST_TIME) + notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom + notify_at_custom = data.get('notify_at') + + # Slot-Konflikt prüfen + existing = db.get_article_by_date(date_str, post_time) + if not existing: + return jsonify({'success': False, 'error': 'Kein Artikel für diesen Slot'}) + + # notify_at berechnen + import pytz + from datetime import datetime as dt + berlin = pytz.timezone('Europe/Berlin') + now_berlin = dt.now(berlin) + article_dt = berlin.localize(dt.strptime(f"{date_str} {post_time}", '%Y-%m-%d %H:%M')) + + if notify_mode == 'sofort': + notify_at = dt.utcnow().isoformat() + elif notify_mode == 'custom' and notify_at_custom: + notify_at = notify_at_custom + else: + # Auto: wenn heute → sofort, sonst Vortag 17:00 + if article_dt.date() == now_berlin.date(): + notify_at = dt.utcnow().isoformat() + else: + day_before = (article_dt - timedelta(days=1)).replace(hour=17, minute=0, second=0) + notify_at = day_before.astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S') + + db.schedule_article(date_str, post_time, notify_at) + flog.article_scheduled(date_str, post_time, notify_at) + return jsonify({'success': True, 'notify_at': notify_at}) + + +@app.route('/api/article//reschedule', methods=['POST']) +def api_reschedule(article_id): + data = request.get_json() + new_date = data.get('date') + new_time = data.get('post_time') + if not new_date or not new_time: + return jsonify({'success': False, 'error': 'date und post_time erforderlich'}) + # Slot-Konflikt prüfen + if db.slot_is_taken(new_date, new_time, exclude_id=article_id): + taken = db.get_taken_slots(new_date) + flog.slot_conflict(new_date, new_time) + return jsonify({ + 'success': False, + 'error': f'Slot {new_date} {new_time} ist bereits belegt.', + 'taken_slots': taken + }) + ok = db.reschedule_article(article_id, new_date, new_time) + if ok: + return jsonify({'success': True}) + return jsonify({'success': False, 'error': 'Fehler beim Umplanen'}) + + +@app.route('/api/article//delete', methods=['POST']) +def api_delete(article_id): + db.delete_article(article_id) + return jsonify({'success': True}) + + +@app.route('/api/slots/') +def api_slots(date_str): + """Gibt belegte Slots für einen Tag zurück.""" + taken = db.get_taken_slots(date_str) + return jsonify({'date': date_str, 'taken': taken}) + + +@app.route('/api/article//send-to-bot', methods=['POST']) +def api_send_to_bot(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + article = db.get_article_by_date(date_str, post_time) + if not article or not article.get('content_final'): + return jsonify({'success': False, 'error': 'Kein Artikel vorhanden'}) + channel = db.get_channel() + pt = channel.get('post_time', post_time) + branded = with_branding(article['content_final']) + text = ( + f"📋 Review: {date_str} · {post_time} Uhr\n" + f"Version {article['version']}\n" + f"──────────────────────\n\n" + f"{branded}\n\n" + f"──────────────────────\n" + f"Freigeben oder bearbeiten?" + ) + keyboard = { + "inline_keyboard": [[ + {"text": "✅ Freigeben", "callback_data": f"approve:{date_str}:{post_time}"}, + {"text": "✏️ Bearbeiten", "callback_data": f"edit:{date_str}:{post_time}"} + ]] + } + results = notify_all_reviewers(text, keyboard) + ok_count = sum(1 for r in results if r['ok']) + if ok_count > 0: + db.update_article_status(date_str, 'sent_to_bot', post_time=post_time) + flog.article_sent_to_bot(date_str, post_time, [r['chat_id'] for r in results if r['ok']]) + return jsonify({'success': True}) + return jsonify({'success': False, 'error': 'Kein Reviewer erreichbar'}) + + +@app.route('/api/settings/post-time', methods=['POST']) +def api_save_post_time(): + data = request.get_json() + post_time = data.get('post_time', '19:55') + channel = db.get_channel() + db.update_channel(channel.get('telegram_id', ''), post_time) + return jsonify({'success': True}) + + +@app.route('/api/settings/location', methods=['POST']) +def api_set_location(): + data = request.get_json() + location_id = data.get('location_id') + if not location_id: + return jsonify({'success': False, 'error': 'Keine Location-ID'}) + db.set_location(location_id) + loc = db.get_current_location() + morning, afternoon = db.get_reminder_times_in_berlin(loc) + return jsonify({ + 'success': True, + 'location': loc, + 'reminders_berlin': { + 'morning': f"{morning[0]:02d}:{morning[1]:02d}", + 'afternoon': f"{afternoon[0]:02d}:{afternoon[1]:02d}" + } + }) + + +@app.route('/api/balance') +def api_balance(): + try: + balance = asyncio.run(openrouter.get_balance()) + return jsonify(balance) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/article/') +def api_article(date_str): + post_time = request.args.get('post_time') + article = db.get_article_by_date(date_str, post_time) + if not article: + return jsonify({'error': 'Nicht gefunden'}), 404 + return jsonify(article) + + +@app.route('/api/articles/') +def api_articles_for_date(date_str): + """Alle Artikel eines Tages (alle Slots).""" + articles = db.get_articles_by_date(date_str) + return jsonify(articles) + + +@app.route('/api/article//approve', methods=['POST']) +def api_approve(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + db.update_article_status(date_str, 'approved', post_time=post_time) + flog.article_approved(date_str, post_time, 0) + return jsonify({'success': True}) + + +@app.route('/api/article//skip', methods=['POST']) +def api_skip(date_str): + data = request.get_json() or {} + post_time = data.get('post_time', POST_TIME) + db.update_article_status(date_str, 'skipped', post_time=post_time) + flog.article_skipped(date_str, post_time) + return jsonify({'success': True}) + + +if __name__ == '__main__': + db.init_db() + app.run(host='0.0.0.0', port=8080, debug=False) diff --git a/fuenfvoracht/src/bot.py b/fuenfvoracht/src/bot.py new file mode 100644 index 000000000..969c4569c --- /dev/null +++ b/fuenfvoracht/src/bot.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +FünfVorAcht Bot — Review, Scheduling, Briefing, Fehler-Alarm +""" +import asyncio +import os +from datetime import datetime, timedelta + +import pytz +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot +from telegram.ext import (Application, CallbackQueryHandler, CommandHandler, + MessageHandler, filters, ContextTypes) +from telegram.constants import ParseMode +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +import database as db +import logger as flog + +BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN'] +CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '') +TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin')) +POST_TIME = os.environ.get('POST_TIME', '19:55') + +BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben" +BRAND_SIGNATURE = ( + "Wir schützen die Zukunft unserer Kinder und das Leben❤️\n\n" + "Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n" + "Unterstützt die Menschen, die für Uns einstehen❗️" +) + +edit_pending = {} + + +def today_str(): + return datetime.now(TZ).strftime('%Y-%m-%d') + + +def with_branding(content: str) -> str: + text = (content or "").rstrip() + if BRAND_MARKER in text: + return text + return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE + + +def review_keyboard(date_str: str, post_time: str): + return InlineKeyboardMarkup([[ + InlineKeyboardButton("✅ Freigeben", callback_data=f"approve:{date_str}:{post_time}"), + InlineKeyboardButton("✏️ Bearbeiten", callback_data=f"edit:{date_str}:{post_time}"), + ]]) + + +def is_reviewer(user_id: int) -> bool: + return user_id in db.get_reviewer_chat_ids() + + +async def notify_reviewers(bot: Bot, text: str, parse_mode=ParseMode.HTML, + reply_markup=None): + for chat_id in db.get_reviewer_chat_ids(): + try: + await bot.send_message(chat_id, text, + parse_mode=parse_mode, + reply_markup=reply_markup) + except Exception as e: + flog.error('notify_reviewer_failed', chat_id=chat_id, reason=str(e)) + + +# ── Commands ────────────────────────────────────────────────────────────────── + +async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_reviewer(update.effective_user.id): + return + await update.message.reply_text( + "🕗 FünfVorAcht — Review Bot\n\n" + "Artikel werden im Dashboard erstellt und eingeplant.\n" + "Hier kannst du sie freigeben oder letzte Änderungen vornehmen.\n\n" + "Befehle:\n" + "/heute — Alle Slots von heute\n" + "/queue — Nächste 3 Tage\n" + "/skip — Heutigen Hauptslot überspringen", + parse_mode=ParseMode.HTML + ) + + +async def cmd_heute(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_reviewer(update.effective_user.id): + return + d = today_str() + articles = db.get_articles_by_date(d) + if not articles: + await update.message.reply_text( + f"📭 Noch keine Artikel für heute ({d}).\n" + "👉 Dashboard: http://100.73.171.62:8080" + ) + return + status_map = { + 'draft': '📝 Entwurf', + 'scheduled': '🗓️ Eingeplant', + 'sent_to_bot': '📱 Zum Review gesendet', + 'approved': '✅ Freigegeben', + 'posted': '📤 Gepostet ✓', + 'skipped': '⏭️ Übersprungen', + 'pending_review': '⏳ Wartet auf Freigabe', + } + lines = [f"📅 {d} — {len(articles)} Slot(s)\n"] + for art in articles: + lines.append( + f"{art['post_time']} Uhr · " + f"{status_map.get(art['status'], art['status'])} · " + f"v{art['version']}" + ) + await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML) + + +async def cmd_queue(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_reviewer(update.effective_user.id): + return + lines = ["📆 Nächste 3 Tage:\n"] + status_icons = { + 'draft': '📝', 'scheduled': '🗓️', 'sent_to_bot': '📱', + 'approved': '✅', 'posted': '📤', 'skipped': '⏭️', + } + for i in range(3): + d = (datetime.now(TZ) + timedelta(days=i)).strftime('%Y-%m-%d') + arts = db.get_articles_by_date(d) + if arts: + slots = ', '.join( + f"{a['post_time']} {status_icons.get(a['status'], '❓')}" + for a in arts + ) + lines.append(f"{d}: {slots}") + else: + lines.append(f"❌ {d} — keine Artikel") + await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML) + + +async def cmd_skip(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_reviewer(update.effective_user.id): + return + d = today_str() + channel = db.get_channel() + pt = channel.get('post_time', POST_TIME) + art = db.get_article_by_date(d, pt) + if not art: + db.create_article(d, "SKIP", "", None, "allgemein", pt) + db.update_article_status(d, 'skipped', post_time=pt) + flog.article_skipped(d, pt) + await update.message.reply_text(f"⏭️ {d} {pt} Uhr übersprungen.") + + +# ── Callbacks ───────────────────────────────────────────────────────────────── + +async def handle_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + parts = query.data.split(':', 2) + action = parts[0] + date_str = parts[1] if len(parts) > 1 else today_str() + post_time = parts[2] if len(parts) > 2 else POST_TIME + + if action == "approve": + db.update_article_status(date_str, 'approved', + query.message.message_id, + query.message.chat_id, + post_time=post_time) + article = db.get_article_by_date(date_str, post_time) + flog.article_approved(date_str, post_time, query.message.chat_id) + await query.edit_message_text( + f"✅ Freigegeben!\n\n" + f"Wird automatisch um {post_time} Uhr gepostet.\n\n" + f"{article['content_final']}", + parse_mode=ParseMode.HTML + ) + + elif action == "edit": + article = db.get_article_by_date(date_str, post_time) + edit_pending[f"{date_str}:{post_time}"] = True + await query.edit_message_text( + f"✏️ Bearbeiten — {date_str} {post_time} Uhr\n\n" + f"Schick mir den neuen Text als nächste Nachricht.\n\n" + f"Aktueller Text:\n{article['content_final']}", + parse_mode=ParseMode.HTML + ) + + +# ── Textnachrichten ─────────────────────────────────────────────────────────── + +async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_reviewer(update.effective_user.id): + return + # Suche ob ein Edit-Pending-Slot aktiv ist + pending_key = next((k for k in edit_pending), None) + if not pending_key: + await update.message.reply_text( + "ℹ️ Artikel werden im Dashboard erstellt.\n" + "http://100.73.171.62:8080" + ) + return + date_str, post_time = pending_key.split(':', 1) + new_text = update.message.text.strip() + db.update_article_content(date_str, new_text, post_time=post_time) + del edit_pending[pending_key] + await update.message.reply_text( + f"✏️ Aktualisiert — {date_str} {post_time} Uhr\n\n{new_text}", + parse_mode=ParseMode.HTML, + reply_markup=review_keyboard(date_str, post_time) + ) + db.update_article_status(date_str, 'sent_to_bot', + update.message.message_id, + update.effective_chat.id, + post_time=post_time) + + +# ── Scheduler Jobs ──────────────────────────────────────────────────────────── + +async def job_post_articles(bot: Bot): + """Postet alle freigegebenen Artikel deren post_time jetzt fällig ist.""" + now_berlin = datetime.now(TZ) + d = now_berlin.strftime('%Y-%m-%d') + current_slot = now_berlin.strftime('%H:%M') + + articles = db.get_articles_by_date(d) + for article in articles: + if article['status'] != 'approved': + continue + if article['post_time'] != current_slot: + continue + if not CHANNEL_ID: + await notify_reviewers(bot, "⚠️ Kanal-ID nicht konfiguriert!") + flog.error('no_channel_id', date=d, post_time=current_slot) + return + try: + final_text = with_branding(article['content_final']) + msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML) + db.update_article_status(d, 'posted', post_time=current_slot) + db.save_post_history(d, msg.message_id, post_time=current_slot) + flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id) + await notify_reviewers( + bot, + f"📤 Fünf vor Acht gepostet!\n{d} · {current_slot} Uhr" + ) + except Exception as e: + flog.posting_failed(d, current_slot, str(e)) + await notify_reviewers( + bot, + f"❌ Posting fehlgeschlagen!\n\n" + f"📅 {d} · ⏰ {current_slot} Uhr\n" + f"Kanal: {CHANNEL_ID}\n\n" + f"Ursache: {str(e)[:250]}\n\n" + f"👉 Dashboard: http://100.73.171.62:8080" + ) + + +async def job_check_notify(bot: Bot): + """Prüft alle 5 Min ob scheduled-Artikel zur Bot-Benachrichtigung fällig sind.""" + due = db.get_due_notifications() + for article in due: + d = article['date'] + pt = article['post_time'] + text = ( + f"📋 Review: {d} · {pt} Uhr\n" + f"Version {article['version']}\n" + f"──────────────────────\n\n" + f"{article['content_final']}\n\n" + f"──────────────────────\n" + f"Freigeben oder bearbeiten?" + ) + await notify_reviewers(bot, text, + reply_markup=review_keyboard(d, pt)) + db.update_article_status(d, 'sent_to_bot', post_time=pt) + flog.article_sent_to_bot(d, pt, db.get_reviewer_chat_ids()) + + +async def job_morning_briefing(bot: Bot): + """Morgen-Briefing: was ist heute geplant, was fehlt.""" + d = today_str() + now_berlin = datetime.now(TZ) + channel = db.get_channel() + + articles_today = db.get_articles_by_date(d) + approved = [a for a in articles_today if a['status'] in ('approved', 'scheduled', 'sent_to_bot')] + missing = [a for a in articles_today if a['status'] in ('draft', 'pending_review')] + + # Nächste 3 Tage für Ausblick + plan_lines = [] + for i in range(1, 4): + next_d = (now_berlin + timedelta(days=i)).strftime('%Y-%m-%d') + arts = db.get_articles_by_date(next_d) + if arts: + slots = ', '.join(f"{a['post_time']} ✅" if a['status'] in ('approved', 'scheduled') else f"{a['post_time']} 📝" for a in arts) + plan_lines.append(f" {next_d}: {slots}") + else: + plan_lines.append(f" {next_d}: ❌ leer") + + lines = [f"☀️ Guten Morgen — FünfVorAcht Briefing\n", + f"📅 Heute: {d}"] + + if approved: + lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)") + for a in approved: + lines.append(f" • {a['post_time']} Uhr — {(a['content_final'] or '')[:50]}…") + else: + lines.append("⚠️ Noch kein Artikel für heute freigegeben!") + + if missing: + lines.append(f"📝 Entwürfe (noch nicht freigegeben): {len(missing)}") + + if not articles_today: + lines.append("❌ Kein Artikel erstellt — bitte jetzt anlegen.") + + lines.append(f"\n📆 Nächste 3 Tage:") + lines.extend(plan_lines) + lines.append(f"\n👉 Dashboard: http://100.73.171.62:8080") + + await notify_reviewers(bot, '\n'.join(lines)) + flog.info('morning_briefing_sent', date=d) + + +async def job_reminder_afternoon(bot: Bot): + """Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben.""" + d = today_str() + channel = db.get_channel() + post_t = channel.get('post_time', POST_TIME) + art = db.get_article_by_date(d, post_t) + if art and art['status'] in ('sent_to_bot', 'pending_review', 'draft', 'scheduled'): + await notify_reviewers( + bot, + f"⚠️ Noch nicht freigegeben!\n\n" + f"Posting um {post_t} Uhr — bitte jetzt freigeben.", + reply_markup=review_keyboard(d, post_t) if art['status'] == 'sent_to_bot' else None + ) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + db.init_db() + + # Standard-Reviewer aus ENV falls DB leer + reviewers_in_db = db.get_reviewers() + if not reviewers_in_db: + import os as _os + raw = _os.environ.get('REVIEW_CHAT_IDS', '') + admin = _os.environ.get('ADMIN_CHAT_ID', '') + ids_raw = [x.strip() for x in raw.split(',') if x.strip()] if raw else [] + if admin and admin not in ids_raw: + ids_raw.append(admin) + for cid in ids_raw: + try: + db.add_reviewer(int(cid), f"Redakteur {cid}") + except Exception: + pass + + application = Application.builder().token(BOT_TOKEN).build() + bot = application.bot + + channel = db.get_channel() + post_t = channel.get('post_time', POST_TIME) if channel else POST_TIME + + location = db.get_current_location() + if location: + (rem_m_h, rem_m_m), (rem_a_h, rem_a_m) = db.get_reminder_times_in_berlin(location) + flog.info('scheduler_init', + location=location['name'], + morning=f"{rem_m_h:02d}:{rem_m_m:02d}", + afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}") + else: + rem_m_h, rem_m_m = 10, 0 + rem_a_h, rem_a_m = 17, 45 + + scheduler = AsyncIOScheduler(timezone=TZ) + # Jede Minute prüfen ob ein Artikel zu posten ist + scheduler.add_job(job_post_articles, 'cron', minute='*', + kwargs={'bot': bot}) + # Alle 5 Min auf fällige Benachrichtigungen prüfen + scheduler.add_job(job_check_notify, 'cron', minute='*/5', + kwargs={'bot': bot}) + # Morgen-Briefing 10:00 MEZ + scheduler.add_job(job_morning_briefing, 'cron', + hour=rem_m_h, minute=rem_m_m, + kwargs={'bot': bot}) + # Nachmittags-Reminder + scheduler.add_job(job_reminder_afternoon, 'cron', + hour=rem_a_h, minute=rem_a_m, + kwargs={'bot': bot}) + scheduler.start() + + flog.info('bot_started', post_time=post_t, + briefing=f"{rem_m_h:02d}:{rem_m_m:02d}", + afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}") + + application.add_handler(CommandHandler("start", cmd_start)) + application.add_handler(CommandHandler("heute", cmd_heute)) + application.add_handler(CommandHandler("queue", cmd_queue)) + application.add_handler(CommandHandler("skip", cmd_skip)) + application.add_handler(CallbackQueryHandler(handle_callback)) + application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) + + application.run_polling(drop_pending_updates=True) + + +if __name__ == '__main__': + main() diff --git a/fuenfvoracht/src/database.py b/fuenfvoracht/src/database.py new file mode 100644 index 000000000..0b068f378 --- /dev/null +++ b/fuenfvoracht/src/database.py @@ -0,0 +1,694 @@ +import sqlite3 +import os +import logging +from datetime import datetime + +import logger as flog + +_logger = logging.getLogger(__name__) +DB_PATH = os.environ.get('DB_PATH', '/data/fuenfvoracht.db') + +DEFAULT_PROMPT = '''Du erstellst einen strukturierten Beitrag für den Telegram-Kanal "Fünf vor Acht". +Der Beitrag präsentiert einen Inhalt (Video, Artikel, Vortrag) neutral und informativ. +Leser sollen sich selbst ein Bild machen können. + +EINGABE: {source} +DATUM: {date} +THEMA: {tag} + +AUFGABE: +Analysiere die Quelle und erstelle einen Telegram-Beitrag nach exakt diesem FORMAT. +Wähle passende Emojis für die Sektions-Überschriften je nach Thema. +Schreibe sachlich und ohne eigene Wertung. + +FORMAT — exakt so ausgeben (Telegram-kompatibel, kein HTML): + +[Kategorie-Emoji] [Typ]: [Vollständiger Titel] + +🔗 [Quelle ansehen / Artikel lesen / Video ansehen]: +[URL aus der Eingabe] + +[Themen-Emoji] Inhaltlicher Schwerpunkt + +[2-3 Sätze: Wer spricht/schreibt worüber und in welchem Kontext] + +Themen im Überblick: +• [Kernthema 1] +• [Kernthema 2] +• [Kernthema 3] +• [Kernthema 4] +• [Kernthema 5] + +[2. Themen-Emoji] [Zweiter Schwerpunkt falls vorhanden] + +[2-3 Sätze zum zweiten Teil] + +• [Unterpunkt 1] +• [Unterpunkt 2] +• [Unterpunkt 3] + +📌 Einordnung + +[2-3 Sätze: Neutrale Beschreibung des Formats/der Methode. Kein Urteil. Leser können Quellen selbst prüfen.]''' + + +def get_conn(): + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + conn = get_conn() + c = conn.cursor() + + # Basis-Tabellen + c.executescript(''' + CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + post_time TEXT NOT NULL DEFAULT '19:55', + source_input TEXT, + content_raw TEXT, + content_final TEXT, + status TEXT DEFAULT 'draft', + version INTEGER DEFAULT 1, + review_message_id INTEGER, + review_chat_id INTEGER, + prompt_id INTEGER, + tag TEXT, + notify_at TEXT, + scheduled_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + sent_to_bot_at TEXT, + approved_at TEXT, + posted_at TEXT, + UNIQUE(date, post_time) + ); + CREATE TABLE IF NOT EXISTS article_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + version_nr INTEGER NOT NULL, + content TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (article_id) REFERENCES articles(id) + ); + CREATE TABLE IF NOT EXISTS post_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + channel_message_id INTEGER, + posted_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (article_id) REFERENCES articles(id) + ); + CREATE TABLE IF NOT EXISTS prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + system_prompt TEXT NOT NULL, + is_default INTEGER DEFAULT 0, + last_tested_at TEXT, + test_result TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS sources_favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + url TEXT, + used_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS article_tags ( + article_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (article_id, tag_id), + FOREIGN KEY (article_id) REFERENCES articles(id), + FOREIGN KEY (tag_id) REFERENCES tags(id) + ); + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + telegram_id TEXT, + post_time TEXT DEFAULT '19:55', + timezone TEXT DEFAULT 'Europe/Berlin', + active INTEGER DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + flag TEXT NOT NULL, + timezone TEXT NOT NULL, + reminder_morning TEXT DEFAULT '10:00', + reminder_afternoon TEXT DEFAULT '18:00' + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS reviewers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL, + active INTEGER DEFAULT 1, + added_at TEXT DEFAULT (datetime('now')) + ); + ''') + + # Migration: post_time Spalte zu bestehender articles-Tabelle hinzufügen falls fehlt + cols = [r[1] for r in c.execute("PRAGMA table_info(articles)").fetchall()] + if 'post_time' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN post_time TEXT NOT NULL DEFAULT '19:55'") + _logger.info("Migration: post_time Spalte hinzugefügt") + if 'notify_at' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN notify_at TEXT") + if 'scheduled_at' not in cols: + c.execute("ALTER TABLE articles ADD COLUMN scheduled_at TEXT") + + # reviewers-Tabelle Migration + reviewer_cols = [r[1] for r in c.execute("PRAGMA table_info(reviewers)").fetchall()] + if not reviewer_cols: + _logger.info("Migration: reviewers Tabelle erstellt") + + # Standard-Daten + c.execute("SELECT COUNT(*) FROM prompts") + if c.fetchone()[0] == 0: + c.execute( + "INSERT INTO prompts (name, system_prompt, is_default) VALUES (?, ?, 1)", + ("Standard", DEFAULT_PROMPT) + ) + c.execute("SELECT COUNT(*) FROM channels") + if c.fetchone()[0] == 0: + c.execute( + "INSERT INTO channels (name, telegram_id, post_time, timezone) VALUES (?, ?, ?, ?)", + ("Fünf vor Acht", "", "19:55", "Europe/Berlin") + ) + for tag in ["Politik", "Wirtschaft", "Tech", "Gesellschaft", "Umwelt", "Kultur", "Sport"]: + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + + locations = [ + ("Deutschland", "🇩🇪", "Europe/Berlin", "10:00", "18:00"), + ("Kambodscha", "🇰🇭", "Asia/Phnom_Penh", "10:00", "18:00"), + ("Thailand", "🇹🇭", "Asia/Bangkok", "10:00", "18:00"), + ("USA Ostküste", "🇺🇸", "America/New_York", "10:00", "18:00"), + ("USA Westküste","🇺🇸", "America/Los_Angeles", "10:00", "18:00"), + ("Spanien", "🇪🇸", "Europe/Madrid", "10:00", "18:00"), + ] + c.execute("SELECT COUNT(*) FROM locations") + if c.fetchone()[0] == 0: + for loc in locations: + c.execute( + "INSERT INTO locations (name, flag, timezone, reminder_morning, reminder_afternoon) VALUES (?,?,?,?,?)", + loc + ) + + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('user_location_id', '1')") + + conn.commit() + conn.close() + _logger.info("DB initialisiert: %s", DB_PATH) + + +# ── Article CRUD ────────────────────────────────────────────────────────────── + +def get_article_by_date(date_str, post_time=None): + """Gibt ersten Artikel des Tages zurück, oder den mit spezifischer post_time.""" + conn = get_conn() + if post_time: + row = conn.execute( + "SELECT * FROM articles WHERE date=? AND post_time=?", (date_str, post_time) + ).fetchone() + else: + row = conn.execute( + "SELECT * FROM articles WHERE date=? ORDER BY post_time ASC LIMIT 1", (date_str,) + ).fetchone() + conn.close() + return dict(row) if row else None + + +def get_articles_by_date(date_str): + """Gibt alle Artikel eines Tages zurück (mehrere Slots).""" + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles WHERE date=? ORDER BY post_time ASC", (date_str,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_article_by_id(article_id): + conn = get_conn() + row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +def slot_is_taken(date_str, post_time, exclude_id=None): + """Prüft ob ein Zeitslot bereits belegt ist.""" + conn = get_conn() + if exclude_id: + row = conn.execute( + "SELECT id FROM articles WHERE date=? AND post_time=? AND id!=? AND status NOT IN ('skipped','rejected')", + (date_str, post_time, exclude_id) + ).fetchone() + else: + row = conn.execute( + "SELECT id FROM articles WHERE date=? AND post_time=? AND status NOT IN ('skipped','rejected')", + (date_str, post_time) + ).fetchone() + conn.close() + return row is not None + + +def get_taken_slots(date_str): + """Gibt alle belegten Zeitslots eines Tages zurück.""" + conn = get_conn() + rows = conn.execute( + "SELECT post_time FROM articles WHERE date=? AND status NOT IN ('skipped','rejected')", + (date_str,) + ).fetchall() + conn.close() + return [r[0] for r in rows] + + +def create_article(date_str, source_input, content, prompt_id, tag="allgemein", post_time="19:55"): + conn = get_conn() + try: + conn.execute( + "INSERT INTO articles (date, post_time, source_input, content_raw, content_final, prompt_id, tag) VALUES (?,?,?,?,?,?,?)", + (date_str, post_time, source_input, content, content, prompt_id, tag) + ) + conn.execute( + "INSERT INTO article_versions (article_id, version_nr, content) " + "VALUES ((SELECT id FROM articles WHERE date=? AND post_time=?), 1, ?)", + (date_str, post_time, content) + ) + conn.commit() + flog.article_generated(date_str, source_input or '', 1, tag) + finally: + conn.close() + + +def reschedule_article(article_id, new_date, new_post_time): + """Verschiebt einen Artikel auf einen neuen Datum/Zeit-Slot.""" + conn = get_conn() + try: + conn.execute( + "UPDATE articles SET date=?, post_time=? WHERE id=?", + (new_date, new_post_time, article_id) + ) + conn.commit() + flog.info('article_rescheduled', article_id=article_id, + new_date=new_date, new_post_time=new_post_time) + return True + except sqlite3.IntegrityError: + flog.slot_conflict(new_date, new_post_time) + return False + finally: + conn.close() + + +def delete_article(article_id): + conn = get_conn() + conn.execute("DELETE FROM article_versions WHERE article_id=?", (article_id,)) + conn.execute("DELETE FROM articles WHERE id=?", (article_id,)) + conn.commit() + conn.close() + flog.info('article_deleted', article_id=article_id) + + +def update_article_status(date_str, status, message_id=None, chat_id=None, post_time=None): + conn = get_conn() + ts = datetime.utcnow().isoformat() + where = "date=? AND post_time=?" if post_time else "date=?" + params_base = (date_str, post_time) if post_time else (date_str,) + + if status == 'approved': + conn.execute( + f"UPDATE articles SET status=?, approved_at=?, review_message_id=?, review_chat_id=? WHERE {where}", + (status, ts, message_id, chat_id) + params_base + ) + elif status == 'posted': + conn.execute( + f"UPDATE articles SET status=?, posted_at=? WHERE {where}", + (status, ts) + params_base + ) + elif status == 'sent_to_bot': + conn.execute( + f"UPDATE articles SET status=?, sent_to_bot_at=?, review_message_id=?, review_chat_id=? WHERE {where}", + (status, ts, message_id, chat_id) + params_base + ) + elif status == 'scheduled': + conn.execute( + f"UPDATE articles SET status=?, scheduled_at=? WHERE {where}", + (status, ts) + params_base + ) + else: + conn.execute(f"UPDATE articles SET status=? WHERE {where}", (status,) + params_base) + conn.commit() + conn.close() + + +def schedule_article(date_str, post_time, notify_at): + """Artikel einplanen mit Bot-Benachrichtigungs-Zeitpunkt.""" + conn = get_conn() + ts = datetime.utcnow().isoformat() + conn.execute( + "UPDATE articles SET status='scheduled', scheduled_at=?, post_time=?, notify_at=? WHERE date=? AND post_time=?", + (ts, post_time, notify_at, date_str, post_time) + ) + conn.commit() + conn.close() + flog.article_scheduled(date_str, post_time, notify_at) + + +def get_due_notifications(): + """Gibt alle scheduled-Artikel zurück deren notify_at <= jetzt.""" + conn = get_conn() + now = datetime.utcnow().isoformat() + rows = conn.execute( + "SELECT * FROM articles WHERE status='scheduled' AND notify_at IS NOT NULL AND notify_at <= ?", + (now,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def update_article_content(date_str, content, new_version=False, post_time=None): + conn = get_conn() + where = "date=? AND post_time=?" if post_time else "date=?" + params_base = (date_str, post_time) if post_time else (date_str,) + + if new_version: + version = conn.execute( + f"SELECT version FROM articles WHERE {where}", params_base + ).fetchone() + new_v = (version[0] or 1) + 1 + conn.execute( + f"UPDATE articles SET content_raw=?, content_final=?, version=?, status='pending_review' WHERE {where}", + (content, content, new_v) + params_base + ) + article_id = conn.execute( + f"SELECT id FROM articles WHERE {where}", params_base + ).fetchone()[0] + conn.execute( + "INSERT INTO article_versions (article_id, version_nr, content) VALUES (?,?,?)", + (article_id, new_v, content) + ) + else: + conn.execute( + f"UPDATE articles SET content_final=? WHERE {where}", (content,) + params_base + ) + conn.commit() + conn.close() + + +def save_post_history(date_str, channel_message_id, post_time=None): + conn = get_conn() + where = "date=? AND post_time=?" if post_time else "date=?" + params = (date_str, post_time) if post_time else (date_str,) + article_id = conn.execute( + f"SELECT id FROM articles WHERE {where}", params + ).fetchone() + if article_id: + conn.execute( + "INSERT INTO post_history (article_id, channel_message_id) VALUES (?,?)", + (article_id[0], channel_message_id) + ) + conn.commit() + conn.close() + + +def get_recent_articles(limit=30): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles ORDER BY date DESC, post_time ASC LIMIT ?", (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_week_articles(from_date, to_date): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM articles WHERE date BETWEEN ? AND ? ORDER BY date ASC, post_time ASC", + (from_date, to_date) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_last_posted(): + conn = get_conn() + row = conn.execute( + "SELECT date, post_time, posted_at FROM articles WHERE status='posted' ORDER BY posted_at DESC LIMIT 1" + ).fetchone() + conn.close() + return dict(row) if row else None + + +# ── Prompts ─────────────────────────────────────────────────────────────────── + +def get_prompts(): + conn = get_conn() + rows = conn.execute("SELECT * FROM prompts ORDER BY is_default DESC, id ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_default_prompt(): + conn = get_conn() + row = conn.execute("SELECT * FROM prompts WHERE is_default=1 LIMIT 1").fetchone() + if not row: + row = conn.execute("SELECT * FROM prompts LIMIT 1").fetchone() + conn.close() + return dict(row) if row else None + + +def save_prompt(prompt_id, name, system_prompt): + conn = get_conn() + conn.execute("UPDATE prompts SET name=?, system_prompt=? WHERE id=?", (name, system_prompt, prompt_id)) + conn.commit() + conn.close() + + +def create_prompt(name, system_prompt): + conn = get_conn() + conn.execute("INSERT INTO prompts (name, system_prompt) VALUES (?,?)", (name, system_prompt)) + conn.commit() + conn.close() + + +def set_default_prompt(prompt_id): + conn = get_conn() + conn.execute("UPDATE prompts SET is_default=0") + conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (prompt_id,)) + conn.commit() + conn.close() + + +def delete_prompt(prompt_id): + conn = get_conn() + conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (prompt_id,)) + conn.commit() + conn.close() + + +def save_prompt_test_result(prompt_id, result): + conn = get_conn() + conn.execute( + "UPDATE prompts SET test_result=?, last_tested_at=? WHERE id=?", + (result, datetime.utcnow().isoformat(), prompt_id) + ) + conn.commit() + conn.close() + + +# ── Channel / Settings ──────────────────────────────────────────────────────── + +def get_channel(): + conn = get_conn() + row = conn.execute("SELECT * FROM channels WHERE active=1 LIMIT 1").fetchone() + conn.close() + return dict(row) if row else {} + + +def update_channel(telegram_id, post_time="19:55"): + conn = get_conn() + conn.execute( + "UPDATE channels SET telegram_id=?, post_time=? WHERE active=1", + (telegram_id, post_time) + ) + conn.commit() + conn.close() + + +# ── Reviewers ───────────────────────────────────────────────────────────────── + +def get_reviewers(active_only=True): + conn = get_conn() + if active_only: + rows = conn.execute( + "SELECT * FROM reviewers WHERE active=1 ORDER BY added_at ASC" + ).fetchall() + else: + rows = conn.execute("SELECT * FROM reviewers ORDER BY added_at ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_reviewer(chat_id: int, name: str): + conn = get_conn() + try: + conn.execute( + "INSERT INTO reviewers (chat_id, name, active) VALUES (?,?,1)", + (chat_id, name) + ) + conn.commit() + flog.reviewer_added(chat_id, name) + return True + except sqlite3.IntegrityError: + return False + finally: + conn.close() + + +def remove_reviewer(chat_id: int): + conn = get_conn() + conn.execute("UPDATE reviewers SET active=0 WHERE chat_id=?", (chat_id,)) + conn.commit() + conn.close() + flog.reviewer_removed(chat_id) + + +def get_reviewer_chat_ids(): + """Gibt aktive Reviewer-Chat-IDs aus DB zurück, Fallback auf ENV.""" + reviewers = get_reviewers(active_only=True) + if reviewers: + return [r['chat_id'] for r in reviewers] + # Fallback: ENV + import os + ids = [] + raw = os.environ.get('REVIEW_CHAT_IDS', '') + if raw.strip(): + for part in raw.split(','): + try: + ids.append(int(part.strip())) + except ValueError: + pass + admin = os.environ.get('ADMIN_CHAT_ID', '') + if admin: + try: + ids.append(int(admin)) + except ValueError: + pass + unique = [] + for cid in ids: + if cid not in unique: + unique.append(cid) + return unique + + +# ── Favorites ───────────────────────────────────────────────────────────────── + +def get_favorites(): + conn = get_conn() + rows = conn.execute("SELECT * FROM sources_favorites ORDER BY used_count DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_favorite(label, url): + conn = get_conn() + conn.execute("INSERT INTO sources_favorites (label, url) VALUES (?,?)", (label, url)) + conn.commit() + conn.close() + + +def increment_favorite(fav_id): + conn = get_conn() + conn.execute("UPDATE sources_favorites SET used_count=used_count+1 WHERE id=?", (fav_id,)) + conn.commit() + conn.close() + + +# ── Tags ────────────────────────────────────────────────────────────────────── + +def get_tags(): + conn = get_conn() + rows = conn.execute("SELECT * FROM tags ORDER BY name").fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ── Locations ───────────────────────────────────────────────────────────────── + +def get_locations(): + conn = get_conn() + rows = conn.execute("SELECT * FROM locations ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_current_location(): + conn = get_conn() + loc_id = conn.execute( + "SELECT value FROM settings WHERE key='user_location_id'" + ).fetchone() + if not loc_id: + conn.close() + return None + row = conn.execute("SELECT * FROM locations WHERE id=?", (loc_id[0],)).fetchone() + conn.close() + return dict(row) if row else None + + +def set_location(location_id): + conn = get_conn() + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('user_location_id', ?)", + (str(location_id),) + ) + conn.commit() + conn.close() + + +def get_reminder_times_in_berlin(location: dict) -> tuple: + import pytz + from datetime import datetime, date + user_tz = pytz.timezone(location['timezone']) + berlin_tz = pytz.timezone('Europe/Berlin') + today = date.today() + + def convert(local_time_str): + h, m = map(int, local_time_str.split(':')) + local_dt = user_tz.localize(datetime(today.year, today.month, today.day, h, m)) + berlin_dt = local_dt.astimezone(berlin_tz) + return berlin_dt.hour, berlin_dt.minute + + return convert(location['reminder_morning']), convert(location['reminder_afternoon']) + + +# ── Stats ───────────────────────────────────────────────────────────────────── + +def get_monthly_stats(): + conn = get_conn() + from datetime import date + month = date.today().strftime('%Y-%m') + total = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ?", (f"{month}%",) + ).fetchone()[0] + posted = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='posted'", (f"{month}%",) + ).fetchone()[0] + skipped = conn.execute( + "SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='skipped'", (f"{month}%",) + ).fetchone()[0] + avg_version = conn.execute( + "SELECT AVG(version) FROM articles WHERE date LIKE ?", (f"{month}%",) + ).fetchone()[0] or 0 + conn.close() + return {"total": total, "posted": posted, "skipped": skipped, "avg_version": round(avg_version, 1)} diff --git a/fuenfvoracht/src/logger.py b/fuenfvoracht/src/logger.py new file mode 100644 index 000000000..2adf7796c --- /dev/null +++ b/fuenfvoracht/src/logger.py @@ -0,0 +1,100 @@ +""" +Strukturiertes Logging für FünfVorAcht. +Schreibt JSON-Lines nach /logs/fuenfvoracht.log +""" +import json +import logging +import os +from datetime import datetime + +LOG_PATH = os.environ.get('LOG_PATH', '/logs/fuenfvoracht.log') + +_file_handler = None + + +def _get_file_handler(): + global _file_handler + if _file_handler is None: + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + _file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8') + _file_handler.setLevel(logging.DEBUG) + return _file_handler + + +def _write(level: str, event: str, **kwargs): + entry = { + 'ts': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), + 'level': level, + 'event': event, + **kwargs, + } + line = json.dumps(entry, ensure_ascii=False) + try: + handler = _get_file_handler() + record = logging.LogRecord( + name='fuenfvoracht', level=getattr(logging, level), + pathname='', lineno=0, msg=line, args=(), exc_info=None + ) + handler.emit(record) + except Exception: + pass + # Auch in stdout damit docker logs es zeigt + print(line, flush=True) + + +def info(event: str, **kwargs): + _write('INFO', event, **kwargs) + + +def warning(event: str, **kwargs): + _write('WARNING', event, **kwargs) + + +def error(event: str, **kwargs): + _write('ERROR', event, **kwargs) + + +# Kurzformen für häufige Events +def article_generated(date: str, source: str, version: int, tag: str): + info('article_generated', date=date, source=source[:120], version=version, tag=tag) + + +def article_saved(date: str, post_time: str): + info('article_saved', date=date, post_time=post_time) + + +def article_scheduled(date: str, post_time: str, notify_at: str): + info('article_scheduled', date=date, post_time=post_time, notify_at=notify_at) + + +def article_sent_to_bot(date: str, post_time: str, chat_ids: list): + info('article_sent_to_bot', date=date, post_time=post_time, chat_ids=chat_ids) + + +def article_approved(date: str, post_time: str, by_chat_id: int): + info('article_approved', date=date, post_time=post_time, by_chat_id=by_chat_id) + + +def article_posted(date: str, post_time: str, channel_id: str, message_id: int): + info('article_posted', date=date, post_time=post_time, + channel_id=channel_id, message_id=message_id) + + +def article_skipped(date: str, post_time: str): + info('article_skipped', date=date, post_time=post_time) + + +def posting_failed(date: str, post_time: str, reason: str): + error('posting_failed', date=date, post_time=post_time, reason=reason[:300]) + + +def reviewer_added(chat_id: int, name: str): + info('reviewer_added', chat_id=chat_id, name=name) + + +def reviewer_removed(chat_id: int): + info('reviewer_removed', chat_id=chat_id) + + +def slot_conflict(date: str, post_time: str): + warning('slot_conflict', date=date, post_time=post_time) diff --git a/fuenfvoracht/src/openrouter.py b/fuenfvoracht/src/openrouter.py new file mode 100644 index 000000000..47e6e107c --- /dev/null +++ b/fuenfvoracht/src/openrouter.py @@ -0,0 +1,72 @@ +import os +import logging +import aiohttp +import asyncio + +logger = logging.getLogger(__name__) + +OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '') +OPENROUTER_BASE = "https://openrouter.ai/api/v1" +DEFAULT_MODEL = os.environ.get('AI_MODEL', 'openai/gpt-4o-mini') + + +async def generate_article(source: str, prompt_template: str, date_str: str, tag: str = "allgemein") -> str: + system_prompt = prompt_template.format( + source=source, + date=date_str, + tag=tag.lower().replace(" ", "") + ) + payload = { + "model": DEFAULT_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Schreibe jetzt den Artikel basierend auf dieser Quelle:\n\n{source}"} + ], + "max_tokens": 600, + "temperature": 0.8 + } + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://fuenfvoracht.orbitalo.net", + "X-Title": "FünfVorAcht Bot" + } + async with aiohttp.ClientSession() as session: + async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp: + data = await resp.json() + if resp.status != 200: + raise Exception(f"OpenRouter Fehler {resp.status}: {data}") + return data["choices"][0]["message"]["content"].strip() + + +async def get_balance() -> dict: + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json" + } + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{OPENROUTER_BASE}/auth/key", headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + key_data = data.get("data", {}) + limit = key_data.get("limit") + usage = key_data.get("usage", 0) + if limit: + remaining = round(limit - usage, 4) + else: + remaining = None + return { + "usage": round(usage, 4), + "limit": limit, + "remaining": remaining, + "label": key_data.get("label", ""), + "is_free_tier": key_data.get("is_free_tier", False) + } + except Exception as e: + logger.error("Balance-Abfrage fehlgeschlagen: %s", e) + return {"usage": None, "limit": None, "remaining": None} + + +def get_balance_sync() -> dict: + return asyncio.run(get_balance()) diff --git a/fuenfvoracht/src/requirements-bot.txt b/fuenfvoracht/src/requirements-bot.txt new file mode 100644 index 000000000..dfa8e16b8 --- /dev/null +++ b/fuenfvoracht/src/requirements-bot.txt @@ -0,0 +1,4 @@ +python-telegram-bot==20.7 +apscheduler==3.10.4 +aiohttp==3.9.3 +pytz==2024.1 diff --git a/fuenfvoracht/src/requirements-web.txt b/fuenfvoracht/src/requirements-web.txt new file mode 100644 index 000000000..70ee41519 --- /dev/null +++ b/fuenfvoracht/src/requirements-web.txt @@ -0,0 +1,5 @@ +flask==3.0.2 +aiohttp==3.9.3 +pytz==2024.1 +gunicorn==21.2.0 +requests==2.31.0 diff --git a/fuenfvoracht/src/templates/hilfe.html b/fuenfvoracht/src/templates/hilfe.html new file mode 100644 index 000000000..f5beb6344 --- /dev/null +++ b/fuenfvoracht/src/templates/hilfe.html @@ -0,0 +1,383 @@ + + + + + +Anleitung — FünfVorAcht + + + + + + + + +
+ +
+

Anleitung

+ FünfVorAcht — KI-gestützter Telegram-Poster +
+ + +
+

⚡ Schnellübersicht — Normaler Tagesablauf

+
+
1. Quelle eingeben
+ +
2. Artikel generieren
+ +
3. Redigieren & speichern
+ +
4. Einplanen (Uhrzeit)
+ +
5. Zum Bot senden
+ +
6. Im Bot freigeben ✅
+ +
7. Automatisch gepostet 📤
+
+
+ + +
+ +
+ ✏️ +

1. Artikel erstellen

+
+ +
+
+ +
+
+
Quelle eingeben
+

URL eines Artikels, Videos oder Vortrags einfügen — oder ein Thema als Text beschreiben.

+

Tipp: Häufig genutzte Quellen als Favoriten speichern → Dropdown nutzen.

+
+
+
Thema & Prompt wählen
+

Tag (z.B. Politik, Tech) und den gewünschten KI-Prompt auswählen.

+

Prompts können unter Prompts bearbeitet und getestet werden.

+
+
+ +
+
⚡ Generieren
+

Button Artikel generieren klicken — die KI erstellt einen fertigen Telegram-Beitrag. Rechts erscheint sofort die Telegram-Vorschau.

+

Nicht zufrieden? Neu generieren erstellt eine neue Version (v2, v3 …). Alle Versionen werden gespeichert.

+
+ +
+
✍️ Redigieren
+

Text im Editor direkt bearbeiten. Die Telegram-Vorschau aktualisiert sich in Echtzeit. Zeichenanzahl wird live angezeigt (max. 4096).

+

Das Markenzeichen wird automatisch am Ende eingefügt — nicht manuell nötig.

+
+ +
+
+ + +
+ +
+ 📅 +

2. Zeitlich einplanen

+ NEU +
+ +
+
+ +
+
📅 Einplanen-Panel
+

Nach dem Generieren auf Einplanen klicken. Ein Panel öffnet sich:

+
    +
  • Datum — aus dem Redaktionsplan übernommen oder frei wählbar
  • +
  • Uhrzeit — 15-Minuten-Raster (z.B. 07:00, 19:45, 19:55)
  • +
  • Bot-Benachrichtigung — Sofort / Vortag 17:00 / Posting-Tag 10:00
  • +
+
+ +
+
+
Artikel für heute
+

Bot-Benachrichtigung: Sofort vorausgewählt

+
+
+
Artikel für morgen/später
+

Automatisch: Vortag 17:00 Uhr vorausgewählt

+
+
+
Zeitkonflikt
+

Blockiert wenn Slot belegt — belegte Zeiten sind ausgegraut

+
+
+ +
+ ℹ️ Mehrere Posts pro Tag: Für jeden Zeitslot einen eigenen Artikel anlegen. Jeder Slot wird unabhängig eingeplant und gepostet. +
+ +
+
+ + +
+ +
+ 📱 +

3. Freigabe & Review im Telegram-Bot

+ BOT +
+ +
+
+ +
+
+
Review erhalten
+

Zum geplanten Zeitpunkt (oder sofort bei manuell senden) schickt der Bot den Artikel an alle Redakteure mit zwei Buttons:

+
+ ✅ Freigeben + ✏️ Bearbeiten +
+
+
+
Bearbeiten im Bot
+

✏️ drücken → Bot zeigt aktuellen Text → einfach neue Version als nächste Nachricht schicken → Bot bestätigt + zeigt erneut Review-Buttons.

+
+
+ +
+
Bot-Befehle
+
+
/start Übersicht & Hilfe
+
/heute Alle Slots von heute
+
/queue Nächste 3 Tage
+
/skip Hauptslot heute überspringen
+
+
+ +
+
☀️ Morgen-Briefing (10:00 Uhr MEZ)
+

Täglich um 10:00 Uhr schickt der Bot automatisch einen Überblick:

+
    +
  • • Welche Slots heute geplant sind (mit Status)
  • +
  • • Ob noch etwas fehlt oder freigegeben werden muss
  • +
  • • Ausblick auf die nächsten 3 Tage
  • +
+
+ +
+
+ + +
+ +
+ 📤 +

4. Automatisches Posting

+
+ +
+
+ +
+
Ablauf
+

Der Scheduler prüft jede Minute: gibt es einen freigegebenen Artikel dessen Uhrzeit jetzt fällig ist?

+
    +
  • Freigegeben + Uhrzeit erreicht → wird in den Kanal gepostet
  • +
  • Nicht freigegeben → Nachmittags-Reminder (18:00 Uhr)
  • +
  • Fehler beim Posting → sofortiger Fehler-Alarm an alle Redakteure
  • +
+
+ +
+
+
+
Freigegeben
+
Postet automatisch
+
+
+
⚠️
+
Kein Artikel
+
Alarm + überspringen
+
+
+
+
Posting-Fehler
+
Sofort-Alarm mit Ursache
+
+
+ +
+ Das Markenzeichen wird automatisch unter jeden Beitrag gesetzt — auch wenn es im Editor noch nicht sichtbar ist. +
+ +
+
+ + +
+ +
+ 🗂️ +

5. Redaktionsplan verwalten

+ NEU +
+ +
+
+ +
+
+
📅 Tag anklicken
+

Klick auf einen Tag im Redaktionsplan lädt den Artikel direkt ins Studio — ohne neu generieren.

+
+
+
🔄 Umplanen
+

Direkt im Board: neues Datum oder Uhrzeit wählen. Bei Zeitkonflikt wird geblockt und ein freier Slot vorgeschlagen.

+
+
+
🗑️ Löschen
+

Artikel aus einem Slot entfernen — mit Sicherheitsabfrage. Slot wird danach wieder als frei angezeigt.

+
+
+ +
+
Status-Übersicht
+
+
📝
Entwurf
+
🗓️
Eingeplant
+
📱
Beim Bot
+
Freigegeben
+
📤
Gepostet
+
⏭️
Skip
+
+
+ +
+
+ + +
+ +
+ ⚙️ +

6. Einstellungen

+
+ +
+
+ +
+
+
📢 Telegram Kanal
+

Kanal-ID oder @username des Ziel-Kanals eintragen. Der Bot muss Admin im Kanal sein.

+
+
+
⏰ Standard-Posting-Zeit
+

Default-Uhrzeit für neue Artikel. Kann pro Artikel beim Einplanen überschrieben werden.

+
+
+
👥 Redakteure
+

Neue Redakteure per Chat-ID hinzufügen. Beim Hinzufügen erhält der neue Redakteur automatisch eine Willkommensnachricht. Chat-ID herausfinden: @userinfobot in Telegram.

+
+
+
📌 Quellen-Favoriten
+

Häufig genutzte URLs speichern — erscheinen im Studio als Dropdown für schnellen Zugriff.

+
+
+
🌍 Aufenthaltsort
+

Aktuellen Standort einstellen. Die Reminder-Zeiten werden automatisch auf MEZ umgerechnet.

+
+
+
🧠 Prompts
+

KI-Prompts erstellen, bearbeiten und mit einer Testquelle ausprobieren. Default-Prompt für neue Artikel festlegen.

+
+
+ +
+
+ + +
+ +
+ +

Häufige Fragen

+
+ +
+
+ +
+
Was passiert wenn ich vergesse freizugeben?
+

Um 18:00 Uhr kommt ein Reminder. Falls bis zur Posting-Zeit kein freigegebener Artikel vorhanden ist, wird der Slot übersprungen und ein Alarm gesendet.

+
+ +
+
Kann ich einen bereits geposteten Artikel bearbeiten?
+

Im Dashboard nicht rückwirkend — aber in Telegram kannst du die Nachricht direkt bearbeiten (Telegram-Editier-Funktion).

+
+ +
+
Wo finde ich die Chat-ID für einen neuen Redakteur?
+

In Telegram @userinfobot anschreiben → gibt die eigene Chat-ID zurück. Oder die Person schreibt dem @Diendemleben_bot — die ID erscheint dann im Bot-Log.

+
+ +
+
Wie sehe ich ob der Bot läuft?
+

Im Dashboard-Header: letzter Post-Zeitstempel. Im Bot: /start senden — Antwort bedeutet Bot ist aktiv. Auf dem Server: docker ps in CT 112.

+
+ +
+
Kann ich mehrere Artikel pro Tag planen?
+

Ja — jeden Zeitslot (15-Min-Raster) einmal belegen. Jeder Slot wird unabhängig gepostet. Doppelt belegte Slots werden automatisch blockiert.

+
+ +
+
+ +
+ FünfVorAcht · CT 112 auf pve-hetzner · Dashboard: fuenfvoracht.orbitalo.net +
+ +
+ + + + + diff --git a/fuenfvoracht/src/templates/history.html b/fuenfvoracht/src/templates/history.html new file mode 100644 index 000000000..7a2558723 --- /dev/null +++ b/fuenfvoracht/src/templates/history.html @@ -0,0 +1,69 @@ + + + + + +History — FünfVorAcht + + + + + + +
+

📋 Artikel-History

+
+ {% for art in articles %} +
+
+
+ {{ art.date }} + + {{ {'posted': '📤 Gepostet', 'approved': '✅ Freigegeben', + 'pending_review': '⏳ Offen', 'skipped': '⏭️ Skip'}.get(art.status, art.status) }} + + v{{ art.version }} +
+ +
+
+ Quelle: {{ art.source_input[:80] if art.source_input else '—' }} +
+ +
+ {% else %} +
Noch keine Artikel vorhanden.
+ {% endfor %} +
+
+ + + diff --git a/fuenfvoracht/src/templates/index.html b/fuenfvoracht/src/templates/index.html new file mode 100644 index 000000000..ecaeb805a --- /dev/null +++ b/fuenfvoracht/src/templates/index.html @@ -0,0 +1,735 @@ + + + + + +🕗 FünfVorAcht Dashboard + + + + + + + + +
+ + +
+
+

✏️ Artikel-Studio — {{ today }}

+
+ {% if article_today %} + + {{ {'draft':'📝 Entwurf','sent_to_bot':'📱 Beim Bot','approved':'✅ Freigegeben','posted':'📤 Gepostet','skipped':'⏭️ Übersprungen','pending_review':'⏳ Offen'}.get(article_today.status, article_today.status) }} + + {% else %} + 📝 Neu + {% endif %} + +
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+ + +
+ + +
+
+ + +
+
+ Vorschau erscheint beim Bearbeiten… +
+
+ + +
+
+

⏰ Posting

+
+ + 🇩🇪 + + +
+
+
+ {{ channel.post_time or '19:55' }} Uhr 🇩🇪 + +
+
+
+ +
+
+
+ + +
+ + +
+
+

📅 Redaktionsplan — Nächste 7 Tage

+ Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪 +
+
+ {% set wday_names = {'0':'Mo','1':'Di','2':'Mi','3':'Do','4':'Fr','5':'Sa','6':'So'} %} + {% for d in plan_days %} + {% set art = plan_articles.get(d) %} + {% set is_today = (d == today) %} +
+ + +
+
+ {% set d_date = d.split('-') %} + {% set weekday_idx = (d_date[0]|int, d_date[1]|int, d_date[2]|int) %} + {{ d[8:] }}.{{ d[5:7] }}. +
+
+ {% if is_today %}Heute{% else %}{{ d }}{% endif %} +
+
+ + +
+ {{ channel.post_time or '19:55' }} +
+ + +
+ {% if art %} + {% if art.status == 'posted' %}📤 + {% elif art.status == 'approved' %}✅ + {% elif art.status == 'sent_to_bot' %}📱 + {% elif art.status in ('pending_review','draft') %}📝 + {% else %}⏭️{% endif %} + {% else %}{% endif %} +
+ + +
+ {% if art and art.content_final %} +
{{ art.content_final[:80] }}
+
v{{ art.version }} · {{ art.tag or '' }}
+ {% else %} +
Kein Artikel geplant
+ {% endif %} +
+ + +
+ {% if art %} + + {{ {'draft':'Entwurf','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'}.get(art.status, art.status) }} + + {% else %} + leer + {% endif %} +
+
+ {% endfor %} +
+
+ + +
+ + +
+

📆 Monatsübersicht

+
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+
+
+ Gepostet + Geplant + Entwurf + Leer +
+
+ + +
+

📊 {{ today[:7] }}

+
+
+
{{ stats.posted }}
+
Gepostet
+
+
+
{{ stats.skipped }}
+
Skip
+
+
+
{{ stats.avg_version }}×
+
Ø Regen.
+
+
+
+
+ 💰 OpenRouter + +
+
laden…
+
+
+ + +
+
+

📋 Letzte Posts

+ Alle → +
+ {% for art in recent[:4] %} +
+ {{ art.date[5:] }} + {{ art.content_final[:50] if art.content_final else '—' }} + {{ {'posted':'📤','approved':'✅','sent_to_bot':'📱','draft':'📝','skipped':'⏭️'}.get(art.status,'?') }} +
+ {% else %} +
Keine Artikel.
+ {% endfor %} +
+ +
+
+ +
+ + + + diff --git a/fuenfvoracht/src/templates/prompts.html b/fuenfvoracht/src/templates/prompts.html new file mode 100644 index 000000000..269af4841 --- /dev/null +++ b/fuenfvoracht/src/templates/prompts.html @@ -0,0 +1,221 @@ + + + + + +Prompts — FünfVorAcht + + + + + + + +
+
+

🧠 Prompt-Bibliothek

+ +
+ +
+ + +
+ {% for p in prompts %} +
+
+
+ {{ p.name }} + {% if p.is_default %} + Standard + {% endif %} +
+ {% if not p.is_default %} +
+ +
+ {% endif %} +
+
{{ p.system_prompt[:100] }}…
+ {% if p.last_tested_at %} +
Zuletzt getestet: {{ p.last_tested_at[:16] }}
+ {% endif %} +
+ {% endfor %} +
+ + +
+
+

Prompt auswählen

+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + diff --git a/fuenfvoracht/src/templates/settings.html b/fuenfvoracht/src/templates/settings.html new file mode 100644 index 000000000..2b2a6b2f0 --- /dev/null +++ b/fuenfvoracht/src/templates/settings.html @@ -0,0 +1,93 @@ + + + + + +Einstellungen — FünfVorAcht + + + + + + +
+

⚙️ Einstellungen

+ + +
+

📢 Telegram Kanal

+
+
+ + +
+
+ + +
+ +
+
+ + +
+

📌 Quellen-Favoriten

+ {% if favorites %} +
+ {% for f in favorites %} +
+
+ {{ f.label }} + {{ f.url[:50] }}{% if f.url|length > 50 %}…{% endif %} +
+ {{ f.used_count }}× +
+ {% endfor %} +
+ {% endif %} +
+ + + +
+
+ + +
+

🏷️ Themen-Tags

+
+ {% for tag in tags %} + #{{ tag.name }} + {% endfor %} +
+
Tags werden im Bot beim Generieren automatisch aus dem Prompt übernommen.
+
+ +
+ +