feat(fuenfvoracht): Anti-Halluzination Auto-5vor8 API und Bot-Benachrichtigungen
- Draft-Status, review_reason, notify-failed Endpoint - Telegram bei Entwurf, erfolgreichem Auto-Approve und Generierungsfehler - Nachmittags-Briefing 15:00 statt 10:00, bessere Entwurf-vs-leer Logik
This commit is contained in:
parent
04f69dfd81
commit
668055d260
2 changed files with 391 additions and 80 deletions
|
|
@ -2,6 +2,7 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for, R
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
import os
|
import os
|
||||||
|
import html
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import requests as req_lib
|
import requests as req_lib
|
||||||
|
|
@ -76,8 +77,13 @@ def planning_days(count=7):
|
||||||
return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)]
|
return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_telegram_html(text: str) -> str:
|
||||||
|
"""Escape <>& so Telegram HTML parse_mode does not fail on stray tags from KI/Quellen."""
|
||||||
|
return html.escape(text or "", quote=False)
|
||||||
|
|
||||||
def with_branding(content: str) -> str:
|
def with_branding(content: str) -> str:
|
||||||
text = (content or "").rstrip()
|
text = sanitize_telegram_html((content or "").rstrip())
|
||||||
if BRAND_MARKER in text:
|
if BRAND_MARKER in text:
|
||||||
return text
|
return text
|
||||||
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
|
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
|
||||||
|
|
@ -105,6 +111,50 @@ def notify_all_reviewers(text, reply_markup=None):
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_auto5vor8_review(date_str, post_time, title="", review_reason="", has_draft=True):
|
||||||
|
"""Telegram an Reviewer: Auto-5vor8 braucht manuelle Prüfung."""
|
||||||
|
title_line = sanitize_telegram_html(title[:120]) if title else "Dr.-Bine-Artikel"
|
||||||
|
reason_line = sanitize_telegram_html(review_reason) if review_reason else ""
|
||||||
|
if has_draft:
|
||||||
|
msg = (
|
||||||
|
f"⚠️ <b>Auto-5vor8: Bitte manuell prüfen</b>\n\n"
|
||||||
|
f"📅 Slot: <b>{date_str} {post_time}</b>\n"
|
||||||
|
f"📰 {title_line}\n"
|
||||||
|
)
|
||||||
|
if reason_line:
|
||||||
|
msg += f"\n❗ <b>Faktencheck:</b> {reason_line}\n"
|
||||||
|
msg += (
|
||||||
|
f"\nDer KI-Text hat die automatische Qualitätsprüfung nicht bestanden. "
|
||||||
|
f"Ein <b>Entwurf</b> liegt im Dashboard — bitte lesen, ggf. anpassen und freigeben.\n\n"
|
||||||
|
f"👉 https://fuenfvoracht.orbitalo.net"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"🚨 <b>Auto-5vor8: Generierung fehlgeschlagen</b>\n\n"
|
||||||
|
f"📰 {title_line}\n"
|
||||||
|
)
|
||||||
|
if reason_line:
|
||||||
|
msg += f"\n❗ {reason_line}\n"
|
||||||
|
msg += (
|
||||||
|
f"\nEs wurde <b>kein</b> Telegram-Text erzeugt. Bitte im Dashboard manuell anlegen.\n\n"
|
||||||
|
f"👉 https://fuenfvoracht.orbitalo.net"
|
||||||
|
)
|
||||||
|
notify_all_reviewers(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_auto5vor8_scheduled(date_str, post_time, title=""):
|
||||||
|
"""Telegram: Auto-5vor8 hat erfolgreich eingeplant."""
|
||||||
|
title_line = sanitize_telegram_html(title[:120]) if title else "Dr.-Bine-Artikel"
|
||||||
|
msg = (
|
||||||
|
f"✅ <b>Auto-5vor8: Artikel eingeplant</b>\n\n"
|
||||||
|
f"📅 <b>{date_str} · {post_time} Uhr</b>\n"
|
||||||
|
f"📰 {title_line}\n\n"
|
||||||
|
f"Wird automatisch gepostet — im Dashboard noch editierbar.\n"
|
||||||
|
f"👉 https://fuenfvoracht.orbitalo.net"
|
||||||
|
)
|
||||||
|
notify_all_reviewers(msg)
|
||||||
|
|
||||||
|
|
||||||
# ── Main Dashboard ────────────────────────────────────────────────────────────
|
# ── Main Dashboard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|
@ -168,7 +218,7 @@ def index():
|
||||||
|
|
||||||
@app.route('/history')
|
@app.route('/history')
|
||||||
def history():
|
def history():
|
||||||
articles = db.get_recent_posted_articles(30)
|
articles = db.get_recent_articles(30)
|
||||||
return render_template('history.html', articles=articles)
|
return render_template('history.html', articles=articles)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -355,6 +405,18 @@ def api_save(date_str):
|
||||||
db.update_article_content(date_str, content, post_time=post_time)
|
db.update_article_content(date_str, content, post_time=post_time)
|
||||||
flog.article_saved(date_str, post_time)
|
flog.article_saved(date_str, post_time)
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
all_today = db.get_articles_by_date(date_str)
|
||||||
|
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'approved', 'pending_review')), None)
|
||||||
|
if candidate:
|
||||||
|
old_time = candidate['post_time']
|
||||||
|
conn = db.get_conn()
|
||||||
|
conn.execute("UPDATE articles SET post_time=? WHERE date=? AND post_time=?", (post_time, date_str, old_time))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
db.update_article_content(date_str, content, post_time=post_time)
|
||||||
|
flog.article_saved(date_str, post_time)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag vorhanden'})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/article/<date_str>/schedule', methods=['POST'])
|
@app.route('/api/article/<date_str>/schedule', methods=['POST'])
|
||||||
|
|
@ -365,33 +427,38 @@ def api_schedule(date_str):
|
||||||
notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom
|
notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom
|
||||||
notify_at_custom = data.get('notify_at')
|
notify_at_custom = data.get('notify_at')
|
||||||
|
|
||||||
# Slot-Konflikt prüfen
|
|
||||||
existing = db.get_article_by_date(date_str, post_time)
|
existing = db.get_article_by_date(date_str, post_time)
|
||||||
if not existing:
|
if not existing:
|
||||||
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Slot'})
|
all_today = db.get_articles_by_date(date_str)
|
||||||
|
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'pending_review')), None)
|
||||||
|
if not candidate:
|
||||||
|
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag gefunden'})
|
||||||
|
old_time = candidate['post_time']
|
||||||
|
conn = db.get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE articles SET post_time=? WHERE date=? AND post_time=?",
|
||||||
|
(post_time, date_str, old_time)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
# notify_at berechnen
|
conn = db.get_conn()
|
||||||
import pytz
|
ts = datetime.utcnow().isoformat()
|
||||||
from datetime import datetime as dt
|
conn.execute(
|
||||||
berlin = pytz.timezone('Europe/Berlin')
|
"UPDATE articles SET status='approved', scheduled_at=?, notify_at=? WHERE date=? AND post_time=?",
|
||||||
now_berlin = dt.now(berlin)
|
(ts, ts, date_str, post_time)
|
||||||
article_dt = berlin.localize(dt.strptime(f"{date_str} {post_time}", '%Y-%m-%d %H:%M'))
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
if notify_mode == 'sofort':
|
notify_all_reviewers(
|
||||||
notify_at = dt.utcnow().isoformat()
|
f"📅 <b>Artikel eingeplant</b>\n\n"
|
||||||
elif notify_mode == 'custom' and notify_at_custom:
|
f"📆 {date_str} um <b>{post_time} Uhr</b>\n"
|
||||||
notify_at = notify_at_custom
|
f"Wird automatisch gepostet."
|
||||||
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, ts)
|
||||||
flog.article_scheduled(date_str, post_time, notify_at)
|
return jsonify({'success': True})
|
||||||
return jsonify({'success': True, 'notify_at': notify_at})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
|
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
|
||||||
|
|
@ -533,13 +600,35 @@ def api_skip(date_str):
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
# ── Auto-5vor8 ────────────────────────────────────────────────────────────────
|
# == Auto-5vor8 (API + Dashboard) ===============================================
|
||||||
|
|
||||||
|
STYLE_ROTATION = [
|
||||||
|
("Standard", "📌 Das Wesentliche"),
|
||||||
|
("Leicht sarkastisch", "🎭 Worum es wirklich geht"),
|
||||||
|
("Humorvoll-serioes", "☕ Kurz erklaert"),
|
||||||
|
("Knapp & direkt", "⚡ In Kuerze"),
|
||||||
|
("Storytelling", "📖 Die Story"),
|
||||||
|
("Wissenschaftlich nuechtern", "🔬 Befund"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _style_for_date(day):
|
||||||
|
from datetime import date as _date
|
||||||
|
if isinstance(day, str):
|
||||||
|
day = _date.fromisoformat(day)
|
||||||
|
return STYLE_ROTATION[day.toordinal() % len(STYLE_ROTATION)]
|
||||||
|
|
||||||
|
|
||||||
def _get_auto5vor8_settings():
|
def _get_auto5vor8_settings():
|
||||||
conn = db.get_conn()
|
conn = db.get_conn()
|
||||||
rows = conn.execute("SELECT key, value FROM auto_5vor8_settings").fetchall()
|
rows = conn.execute("SELECT key, value FROM auto_5vor8_settings").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {r["key"]: r["value"] for r in rows}
|
settings = {r["key"]: r["value"] for r in rows}
|
||||||
|
if "style_rotation" not in settings:
|
||||||
|
settings["style_rotation"] = "1"
|
||||||
|
if "ai_model" not in settings or not settings["ai_model"]:
|
||||||
|
settings["ai_model"] = "deepseek/deepseek-v4-flash"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def _set_auto5vor8_setting(key, value):
|
def _set_auto5vor8_setting(key, value):
|
||||||
|
|
@ -553,30 +642,128 @@ def _set_auto5vor8_setting(key, value):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/auto5vor8')
|
def _auto5vor8_style_info():
|
||||||
|
from datetime import date, timedelta
|
||||||
|
today = date.today()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
t_name, t_intro = _style_for_date(today)
|
||||||
|
n_name, n_intro = _style_for_date(tomorrow)
|
||||||
|
return {
|
||||||
|
"styles": [{"name": n, "intro": i} for n, i in STYLE_ROTATION],
|
||||||
|
"today_style": t_name,
|
||||||
|
"today_intro": t_intro,
|
||||||
|
"tomorrow_style": n_name,
|
||||||
|
"tomorrow_intro": n_intro,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/article/create", methods=["POST"])
|
||||||
|
def api_article_create():
|
||||||
|
"""Erstellt einen Artikel und plant ihn direkt ein (fuer Auto-5vor8)."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "Kein JSON-Body"}), 400
|
||||||
|
content = data.get("content", "").strip()
|
||||||
|
date_str = data.get("date", "").strip()
|
||||||
|
post_time = data.get("post_time", POST_TIME)
|
||||||
|
tag = data.get("tag", "allgemein")
|
||||||
|
source = data.get("source", "")
|
||||||
|
image_url = data.get("image_url", "")
|
||||||
|
if not content or not date_str:
|
||||||
|
return jsonify({"success": False, "error": "content und date erforderlich"}), 400
|
||||||
|
if db.slot_is_taken(date_str, post_time):
|
||||||
|
return jsonify({"success": False, "error": f"Slot {date_str} {post_time} belegt"}), 409
|
||||||
|
article_status = (data.get("status") or "approved").strip().lower()
|
||||||
|
if article_status not in ("approved", "draft"):
|
||||||
|
article_status = "approved"
|
||||||
|
db.create_article(date_str, source, content, None, tag, post_time)
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
ts = _dt.utcnow().isoformat()
|
||||||
|
conn = db.get_conn()
|
||||||
|
if article_status == "approved":
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE articles SET status='approved', scheduled_at=?, image_url=? WHERE date=? AND post_time=?",
|
||||||
|
(ts, image_url, date_str, post_time)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE articles SET status='draft', image_url=? WHERE date=? AND post_time=?",
|
||||||
|
(image_url, date_str, post_time)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if article_status == "draft":
|
||||||
|
_notify_auto5vor8_review(
|
||||||
|
date_str,
|
||||||
|
post_time,
|
||||||
|
title=(data.get("title") or "").strip(),
|
||||||
|
review_reason=(data.get("review_reason") or "").strip(),
|
||||||
|
has_draft=True,
|
||||||
|
)
|
||||||
|
elif (data.get("title") or "").strip():
|
||||||
|
_notify_auto5vor8_scheduled(
|
||||||
|
date_str,
|
||||||
|
post_time,
|
||||||
|
title=(data.get("title") or "").strip(),
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "date": date_str, "post_time": post_time, "status": article_status})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auto5vor8/notify-failed", methods=["POST"])
|
||||||
|
def api_auto5vor8_notify_failed():
|
||||||
|
"""Benachrichtigung wenn Auto-5vor8 gar keinen Text erzeugen konnte."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
_notify_auto5vor8_review(
|
||||||
|
date_str=(data.get("date") or today_str()).strip(),
|
||||||
|
post_time=(data.get("post_time") or POST_TIME).strip(),
|
||||||
|
title=(data.get("title") or "").strip(),
|
||||||
|
review_reason=(data.get("review_reason") or "KI-Text konnte nicht generiert werden").strip(),
|
||||||
|
has_draft=False,
|
||||||
|
)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auto5vor8/settings", methods=["GET"])
|
||||||
|
def api_auto5vor8_settings_get():
|
||||||
|
settings = _get_auto5vor8_settings()
|
||||||
|
settings.update(_auto5vor8_style_info())
|
||||||
|
return jsonify(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auto5vor8/settings", methods=["POST"])
|
||||||
|
def api_auto5vor8_settings_post():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
for key, val in data.items():
|
||||||
|
if val is not None:
|
||||||
|
_set_auto5vor8_setting(key, str(val).strip())
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/auto5vor8")
|
||||||
def auto5vor8():
|
def auto5vor8():
|
||||||
settings = _get_auto5vor8_settings()
|
settings = _get_auto5vor8_settings()
|
||||||
return render_template('auto5vor8.html', settings=settings)
|
style_info = _auto5vor8_style_info()
|
||||||
|
return render_template("auto5vor8.html", settings=settings, style_info=style_info)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/auto5vor8/toggle', methods=['POST'])
|
@app.route("/auto5vor8/toggle", methods=["POST"])
|
||||||
def auto5vor8_toggle():
|
def auto5vor8_toggle():
|
||||||
current = _get_auto5vor8_settings()
|
current = _get_auto5vor8_settings()
|
||||||
new_val = '0' if current.get('enabled') == '1' else '1'
|
new_val = "0" if current.get("enabled") == "1" else "1"
|
||||||
_set_auto5vor8_setting('enabled', new_val)
|
_set_auto5vor8_setting("enabled", new_val)
|
||||||
state = 'aktiviert' if new_val == '1' else 'deaktiviert'
|
state = "aktiviert" if new_val == "1" else "deaktiviert"
|
||||||
logger.info('Auto-5vor8 %s', state)
|
logger.info("Auto-5vor8 %s", state)
|
||||||
return redirect(url_for('auto5vor8'))
|
return redirect(url_for("auto5vor8"))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/auto5vor8/settings', methods=['POST'])
|
@app.route("/auto5vor8/settings", methods=["POST"])
|
||||||
def auto5vor8_save_settings():
|
def auto5vor8_save_settings():
|
||||||
for field in ('prompt', 'post_time', 'ai_model', 'feed_id', 'tag'):
|
for field in ("prompt", "post_time", "ai_model", "feed_id", "tag", "style_rotation"):
|
||||||
val = request.form.get(field)
|
val = request.form.get(field)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
_set_auto5vor8_setting(field, val.strip())
|
_set_auto5vor8_setting(field, val.strip())
|
||||||
logger.info('Auto-5vor8 Einstellungen gespeichert')
|
logger.info("Auto-5vor8 Einstellungen gespeichert")
|
||||||
return redirect(url_for('auto5vor8'))
|
return redirect(url_for("auto5vor8"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ FünfVorAcht Bot — Review, Scheduling, Briefing, Fehler-Alarm
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
@ -16,6 +20,50 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
import database as db
|
import database as db
|
||||||
import logger as flog
|
import logger as flog
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-Send-Check: jede arakavanews-URL muss vor dem Posten 200 liefern.
|
||||||
|
# Faengt drei Klassen von Bugs ab:
|
||||||
|
# 1. KI hat Slug halluziniert (auto_5vor8 sollte das schon repariert haben — Defense in Depth)
|
||||||
|
# 2. WP-Post wurde nachtraeglich geloescht/depubliziert
|
||||||
|
# 3. Cloudflare-Tunnel oder WP down zum Sendezeitpunkt
|
||||||
|
_URL_RE = re.compile(r"https?://arakavanews\.com/\S+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_arakava_links(text: str) -> tuple[bool, str]:
|
||||||
|
"""Prueft alle arakavanews-URLs im Text per HEAD via stdlib. (ok, fehlertext).
|
||||||
|
|
||||||
|
"ok" ist True wenn entweder keine URL drin ist oder ALLE URLs HTTP 200 liefern.
|
||||||
|
Bei mehreren URLs reicht ein einziger Fehlschlag fuer ok=False.
|
||||||
|
Nutzt urllib (stdlib), damit der Bot-Container keine zusaetzliche Dependency
|
||||||
|
(requests) braucht.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return True, ""
|
||||||
|
urls = []
|
||||||
|
for raw in _URL_RE.findall(text):
|
||||||
|
clean = raw.rstrip(').,;:!?"\'<>')
|
||||||
|
if clean and clean not in urls:
|
||||||
|
urls.append(clean)
|
||||||
|
if not urls:
|
||||||
|
return True, ""
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
method="HEAD",
|
||||||
|
headers={"User-Agent": "fuenfvoracht-presend-check/1.0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return False, f"{url} -> HTTP {resp.status}"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return False, f"{url} -> HTTP {e.code}"
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return False, f"{url} -> URLError: {str(e.reason)[:80]}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{url} -> {type(e).__name__}: {str(e)[:80]}"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
|
BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
|
||||||
CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
|
CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
|
||||||
TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin'))
|
TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin'))
|
||||||
|
|
@ -35,8 +83,13 @@ def today_str():
|
||||||
return datetime.now(TZ).strftime('%Y-%m-%d')
|
return datetime.now(TZ).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_telegram_html(text: str) -> str:
|
||||||
|
"""Escape <>& so Telegram HTML parse_mode does not fail on stray tags from KI/Quellen."""
|
||||||
|
return html.escape(text or "", quote=False)
|
||||||
|
|
||||||
def with_branding(content: str) -> str:
|
def with_branding(content: str) -> str:
|
||||||
text = (content or "").rstrip()
|
text = sanitize_telegram_html((content or "").rstrip())
|
||||||
if BRAND_MARKER in text:
|
if BRAND_MARKER in text:
|
||||||
return text
|
return text
|
||||||
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
|
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
|
||||||
|
|
@ -232,10 +285,48 @@ async def job_post_articles(bot: Bot):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
final_text = with_branding(article['content_final'])
|
final_text = with_branding(article['content_final'])
|
||||||
|
|
||||||
|
ok, link_err = await asyncio.to_thread(_check_arakava_links, final_text)
|
||||||
|
if not ok:
|
||||||
|
flog.posting_failed(d, current_slot, f"link_check_failed: {link_err}")
|
||||||
|
await notify_reviewers(
|
||||||
|
bot,
|
||||||
|
f"⛔ <b>Posting abgebrochen — kaputter Link!</b>\n\n"
|
||||||
|
f"📅 {d} · ⏰ {current_slot} Uhr\n\n"
|
||||||
|
f"<b>Fehler:</b>\n<code>{link_err}</code>\n\n"
|
||||||
|
f"Artikel bleibt auf <code>approved</code> (kein Auto-Retry).\n"
|
||||||
|
f"👉 Dashboard: http://100.73.171.62:8080"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_url = article.get('image_url', '') or ''
|
||||||
|
if image_url:
|
||||||
|
try:
|
||||||
|
msg = await bot.send_photo(CHANNEL_ID, photo=image_url, caption=final_text, parse_mode=ParseMode.HTML)
|
||||||
|
except Exception:
|
||||||
|
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
|
||||||
|
else:
|
||||||
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
|
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
|
||||||
db.update_article_status(d, 'posted', post_time=current_slot)
|
db.update_article_status(d, 'posted', post_time=current_slot)
|
||||||
db.save_post_history(d, msg.message_id, 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)
|
flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id)
|
||||||
|
# memory_service_event
|
||||||
|
try:
|
||||||
|
import requests as _rq
|
||||||
|
_rq.post("http://100.121.192.94:8400/events", json={
|
||||||
|
"source": "fuenfvoracht",
|
||||||
|
"event_type": "article_posted",
|
||||||
|
"object_key": f"{d}_{current_slot}",
|
||||||
|
"payload_json": __import__("json").dumps({
|
||||||
|
"date": d,
|
||||||
|
"slot": current_slot,
|
||||||
|
"channel": str(CHANNEL_ID),
|
||||||
|
"message_id": msg.message_id,
|
||||||
|
"title": article.get("title", ""),
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
}, headers={"Authorization": "Bearer Ai8eeQibV6Z1RWc7oNPim4PXB4vILU1nRW2-XgRcX2M"}, timeout=3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await notify_reviewers(
|
await notify_reviewers(
|
||||||
bot,
|
bot,
|
||||||
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
|
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
|
||||||
|
|
@ -253,23 +344,19 @@ async def job_post_articles(bot: Bot):
|
||||||
|
|
||||||
|
|
||||||
async def job_check_notify(bot: Bot):
|
async def job_check_notify(bot: Bot):
|
||||||
"""Prüft alle 5 Min ob scheduled-Artikel zur Bot-Benachrichtigung fällig sind."""
|
"""Prüft alle 5 Min ob scheduled-Artikel fällig sind → direkt approved setzen."""
|
||||||
due = db.get_due_notifications()
|
due = db.get_due_notifications()
|
||||||
for article in due:
|
for article in due:
|
||||||
d = article['date']
|
d = article['date']
|
||||||
pt = article['post_time']
|
pt = article['post_time']
|
||||||
text = (
|
db.update_article_status(d, 'approved', post_time=pt)
|
||||||
f"📋 <b>Review: {d} · {pt} Uhr</b>\n"
|
await notify_reviewers(
|
||||||
f"Version {article['version']}\n"
|
bot,
|
||||||
f"──────────────────────\n\n"
|
f"📅 <b>Artikel eingeplant</b>\n\n"
|
||||||
f"{article['content_final']}\n\n"
|
f"📆 {d} um <b>{pt} Uhr</b>\n"
|
||||||
f"──────────────────────\n"
|
f"Wird automatisch gepostet."
|
||||||
f"Freigeben oder bearbeiten?"
|
|
||||||
)
|
)
|
||||||
await notify_reviewers(bot, text,
|
flog.info('article_auto_approved', date=d, post_time=pt)
|
||||||
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):
|
async def job_morning_briefing(bot: Bot):
|
||||||
|
|
@ -300,52 +387,90 @@ async def job_morning_briefing(bot: Bot):
|
||||||
lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)")
|
lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)")
|
||||||
for a in approved:
|
for a in approved:
|
||||||
lines.append(f" • {a['post_time']} Uhr — {(a['content_final'] or '')[:50]}…")
|
lines.append(f" • {a['post_time']} Uhr — {(a['content_final'] or '')[:50]}…")
|
||||||
|
elif missing:
|
||||||
|
lines.append("⚠️ <b>Entwurf wartet auf Freigabe</b> (Auto-5vor8 Faktencheck)")
|
||||||
|
for a in missing:
|
||||||
|
preview = (a.get('content_final') or a.get('content_raw') or '')[:50]
|
||||||
|
lines.append(f" • {a['post_time']} Uhr — {preview}…")
|
||||||
|
lines.append(" → Bitte im Dashboard prüfen und freigeben.")
|
||||||
else:
|
else:
|
||||||
lines.append("⚠️ Noch kein Artikel für heute freigegeben!")
|
lines.append(
|
||||||
|
"⚠️ <b>Heute noch kein Artikel im Plan.</b>\n"
|
||||||
|
" Der Dr.-Bine-RSS-Lauf ist um 14 Uhr — wenn dort nichts Neues kam, "
|
||||||
|
"bitte manuell anlegen."
|
||||||
|
)
|
||||||
|
|
||||||
if missing:
|
if missing and approved:
|
||||||
lines.append(f"📝 Entwürfe (noch nicht freigegeben): {len(missing)}")
|
lines.append(f"\n📝 Zusätzlich Entwürfe offen: {len(missing)}")
|
||||||
|
for a in missing:
|
||||||
if not articles_today:
|
preview = (a.get('content_final') or a.get('content_raw') or '')[:50]
|
||||||
lines.append("❌ Kein Artikel erstellt — bitte jetzt anlegen.")
|
lines.append(f" • {a['post_time']} Uhr — {preview}…")
|
||||||
|
|
||||||
lines.append(f"\n📆 <b>Nächste 3 Tage:</b>")
|
lines.append(f"\n📆 <b>Nächste 3 Tage:</b>")
|
||||||
lines.extend(plan_lines)
|
lines.extend(plan_lines)
|
||||||
lines.append(f"\n👉 Dashboard: http://100.73.171.62:8080")
|
lines.append(f"\n👉 https://fuenfvoracht.orbitalo.net")
|
||||||
|
|
||||||
await notify_reviewers(bot, '\n'.join(lines))
|
await notify_reviewers(bot, '\n'.join(lines))
|
||||||
flog.info('morning_briefing_sent', date=d)
|
flog.info('morning_briefing_sent', date=d)
|
||||||
|
|
||||||
|
|
||||||
async def job_cleanup_db():
|
async def job_cleanup_db():
|
||||||
"""Wöchentliche DB-Bereinigung: alte Einträge löschen."""
|
"""Woechentliche DB-Bereinigung: alte Eintraege loeschen + VACUUM."""
|
||||||
import sqlite3, os
|
import sqlite3, os
|
||||||
db_path = os.environ.get('DB_PATH', '/data/fuenfvoracht.db')
|
db_path = os.environ.get('DB_PATH', '/data/fuenfvoacht.db')
|
||||||
con = sqlite3.connect(db_path)
|
con = sqlite3.connect(db_path)
|
||||||
# post_history älter als 90 Tage
|
|
||||||
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
|
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
|
||||||
# article_versions älter als 90 Tage
|
|
||||||
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
|
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
|
||||||
# Artikel die bereits gepostet sind und älter als 180 Tage
|
|
||||||
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
|
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
|
||||||
con.execute("VACUUM")
|
|
||||||
con.commit()
|
con.commit()
|
||||||
con.close()
|
con.close()
|
||||||
flog.info('db_cleanup', post_history_deleted=r1, versions_deleted=r2, articles_deleted=r3)
|
vac = sqlite3.connect(db_path, isolation_level=None)
|
||||||
|
vac.execute("VACUUM")
|
||||||
|
vac.close()
|
||||||
|
flog.info('db_cleanup', ph_del=r1, av_del=r2, art_del=r3)
|
||||||
|
|
||||||
|
|
||||||
async def job_reminder_afternoon(bot: Bot):
|
async def job_reminder_afternoon(bot: Bot):
|
||||||
"""Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben."""
|
"""Reminder: Entwurf zur Prüfung, oder Hinweis wenn gar nichts da ist."""
|
||||||
d = today_str()
|
d = today_str()
|
||||||
|
articles = db.get_articles_by_date(d)
|
||||||
|
active_statuses = {'approved', 'scheduled', 'sent_to_bot', 'posted'}
|
||||||
|
active_articles = [a for a in articles if a.get('status') in active_statuses]
|
||||||
|
draft_articles = [a for a in articles if a.get('status') in ('draft', 'pending_review')]
|
||||||
|
|
||||||
|
if active_articles:
|
||||||
|
flog.info(
|
||||||
|
'afternoon_reminder_skipped_article_exists',
|
||||||
|
date=d,
|
||||||
|
slots=[a.get('post_time') for a in active_articles],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if draft_articles:
|
||||||
|
lines = [
|
||||||
|
f"📝 <b>Entwurf wartet auf Freigabe</b>\n",
|
||||||
|
f"📅 Heute ({d}) — Auto-5vor8 hat einen Entwurf angelegt, der noch nicht freigegeben ist:\n",
|
||||||
|
]
|
||||||
|
for a in draft_articles:
|
||||||
|
preview = (a.get('content_final') or a.get('content_raw') or '')[:55]
|
||||||
|
lines.append(f" • {a['post_time']} Uhr — {preview}…")
|
||||||
|
lines.append(
|
||||||
|
"\n⚠️ Der Faktencheck war nicht zufriedenstellend — bitte im Dashboard prüfen, "
|
||||||
|
"anpassen und freigeben, sonst geht heute nichts raus."
|
||||||
|
)
|
||||||
|
lines.append("\n👉 https://fuenfvoracht.orbitalo.net")
|
||||||
|
await notify_reviewers(bot, '\n'.join(lines))
|
||||||
|
flog.info('afternoon_reminder_draft_pending', date=d, count=len(draft_articles))
|
||||||
|
return
|
||||||
|
|
||||||
channel = db.get_channel()
|
channel = db.get_channel()
|
||||||
post_t = channel.get('post_time', POST_TIME)
|
post_t = channel.get('post_time', POST_TIME) if channel else 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(
|
await notify_reviewers(
|
||||||
bot,
|
bot,
|
||||||
f"⚠️ <b>Noch nicht freigegeben!</b>\n\n"
|
f"⚠️ <b>Noch kein Artikel für heute!</b>\n\n"
|
||||||
f"Posting um {post_t} Uhr — bitte jetzt freigeben.",
|
f"Weder freigegeben noch als Entwurf vorhanden.\n"
|
||||||
reply_markup=review_keyboard(d, post_t) if art['status'] == 'sent_to_bot' else None
|
f"Geplanter Slot: {post_t} Uhr.\n"
|
||||||
|
f"👉 https://fuenfvoracht.orbitalo.net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -383,7 +508,7 @@ def main():
|
||||||
morning=f"{rem_m_h:02d}:{rem_m_m:02d}",
|
morning=f"{rem_m_h:02d}:{rem_m_m:02d}",
|
||||||
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
|
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
|
||||||
else:
|
else:
|
||||||
rem_m_h, rem_m_m = 10, 0
|
rem_m_h, rem_m_m = 15, 0
|
||||||
rem_a_h, rem_a_m = 17, 45
|
rem_a_h, rem_a_m = 17, 45
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler(timezone=TZ)
|
scheduler = AsyncIOScheduler(timezone=TZ)
|
||||||
|
|
@ -393,7 +518,7 @@ def main():
|
||||||
# Alle 5 Min auf fällige Benachrichtigungen prüfen
|
# Alle 5 Min auf fällige Benachrichtigungen prüfen
|
||||||
scheduler.add_job(job_check_notify, 'cron', minute='*/5',
|
scheduler.add_job(job_check_notify, 'cron', minute='*/5',
|
||||||
kwargs={'bot': bot})
|
kwargs={'bot': bot})
|
||||||
# Morgen-Briefing 10:00 MEZ
|
# Tages-Briefing 15:00 — nach Dr.-Bine-RSS-Lauf (14:00) + Auto-5vor8
|
||||||
scheduler.add_job(job_morning_briefing, 'cron',
|
scheduler.add_job(job_morning_briefing, 'cron',
|
||||||
hour=rem_m_h, minute=rem_m_m,
|
hour=rem_m_h, minute=rem_m_m,
|
||||||
kwargs={'bot': bot})
|
kwargs={'bot': bot})
|
||||||
|
|
@ -401,7 +526,6 @@ def main():
|
||||||
scheduler.add_job(job_reminder_afternoon, 'cron',
|
scheduler.add_job(job_reminder_afternoon, 'cron',
|
||||||
hour=rem_a_h, minute=rem_a_m,
|
hour=rem_a_h, minute=rem_a_m,
|
||||||
kwargs={'bot': bot})
|
kwargs={'bot': bot})
|
||||||
# Wöchentliche DB-Bereinigung (Sonntags 03:00 Uhr)
|
|
||||||
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
|
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue