homelab-brain/fuenfvoracht/src/bot.py
Homelab Cursor b704a5949a feat(fuenfvoracht): Schreibstil heute/morgen im Tages-Briefing
Bot fragt CT109 /api/auto5vor8/style-preview ab und zeigt den
rotierenden Auto-5vor8-Stil (heute/morgen) im 15-Uhr-Briefing an.
2026-07-31 22:34:53 +02:00

568 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
FünfVorAcht Bot — Review, Scheduling, Briefing, Fehler-Alarm
"""
import asyncio
import os
import html
import re
import urllib.error
import urllib.request
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
# 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']
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 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:
text = sanitize_telegram_html((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(
"🕗 <b>FünfVorAcht — Review Bot</b>\n\n"
"Artikel werden im Dashboard erstellt und eingeplant.\n"
"Hier kannst du sie freigeben oder letzte Änderungen vornehmen.\n\n"
"<b>Befehle:</b>\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"📅 <b>{d}</b> — {len(articles)} Slot(s)\n"]
for art in articles:
lines.append(
f"<b>{art['post_time']} Uhr</b> · "
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 = ["📆 <b>Nächste 3 Tage:</b>\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"<b>{d}</b>: {slots}")
else:
lines.append(f"❌ <b>{d}</b> — 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"✅ <b>Freigegeben!</b>\n\n"
f"Wird automatisch um <b>{post_time} Uhr</b> 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"✏️ <b>Bearbeiten</b> — {date_str} {post_time} Uhr\n\n"
f"Schick mir den neuen Text als nächste Nachricht.\n\n"
f"<i>Aktueller Text:</i>\n{article['content_final']}",
parse_mode=ParseMode.HTML
)
# ── Textnachrichten ───────────────────────────────────────────────────────────
async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user is None:
return
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"✏️ <b>Aktualisiert</b> — {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'])
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)
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)
# 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(
bot,
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
)
except Exception as e:
flog.posting_failed(d, current_slot, str(e))
await notify_reviewers(
bot,
f"❌ <b>Posting fehlgeschlagen!</b>\n\n"
f"📅 {d} · ⏰ {current_slot} Uhr\n"
f"Kanal: {CHANNEL_ID}\n\n"
f"<b>Ursache:</b> {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 fällig sind → direkt approved setzen."""
due = db.get_due_notifications()
for article in due:
d = article['date']
pt = article['post_time']
db.update_article_status(d, 'approved', post_time=pt)
await notify_reviewers(
bot,
f"📅 <b>Artikel eingeplant</b>\n\n"
f"📆 {d} um <b>{pt} Uhr</b>\n"
f"Wird automatisch gepostet."
)
flog.info('article_auto_approved', date=d, post_time=pt)
def _fetch_style_preview():
"""Holt heutigen/morgigen 5vor8-Schreibstil von rss-manager (CT109)."""
try:
req = urllib.request.Request(
"http://10.10.10.109:8080/api/auto5vor8/style-preview",
headers={"User-Agent": "fuenfvoracht-briefing/1.0"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
import json
return json.loads(resp.read().decode("utf-8"))
except Exception:
return None
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"☀️ <b>Guten Morgen — FünfVorAcht Briefing</b>\n",
f"📅 <b>Heute: {d}</b>"]
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]}")
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:
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 and approved:
lines.append(f"\n📝 Zusätzlich Entwürfe offen: {len(missing)}")
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(f"\n📆 <b>Nächste 3 Tage:</b>")
lines.extend(plan_lines)
style_preview = _fetch_style_preview()
if style_preview:
lines.append(f"\n🎨 <b>Schreibstil Auto-5vor8:</b>")
lines.append(f" Heute: {style_preview[today][style]}")
lines.append(f" Morgen: {style_preview[tomorrow][style]}")
lines.append(f"\n👉 https://fuenfvoracht.orbitalo.net")
await notify_reviewers(bot, '\n'.join(lines))
flog.info('morning_briefing_sent', date=d)
async def job_cleanup_db():
"""Woechentliche DB-Bereinigung: alte Eintraege loeschen + VACUUM."""
import sqlite3, os
db_path = os.environ.get('DB_PATH', '/data/fuenfvoacht.db')
con = sqlite3.connect(db_path)
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
con.commit()
con.close()
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):
"""Reminder: Entwurf zur Prüfung, oder Hinweis wenn gar nichts da ist."""
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()
post_t = channel.get('post_time', POST_TIME) if channel else POST_TIME
await notify_reviewers(
bot,
f"⚠️ <b>Noch kein Artikel für heute!</b>\n\n"
f"Weder freigegeben noch als Entwurf vorhanden.\n"
f"Geplanter Slot: {post_t} Uhr.\n"
f"👉 https://fuenfvoracht.orbitalo.net"
)
# ── 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 = 15, 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})
# Tages-Briefing 15:00 — nach Dr.-Bine-RSS-Lauf (14:00) + Auto-5vor8
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.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
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()