#!/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( "🕗 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 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"✏️ 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']) 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"⛔ Posting abgebrochen — kaputter Link!\n\n" f"📅 {d} · ⏰ {current_slot} Uhr\n\n" f"Fehler:\n{link_err}\n\n" f"Artikel bleibt auf approved (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"📤 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 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"📅 Artikel eingeplant\n\n" f"📆 {d} um {pt} Uhr\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"☀️ 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]}…") elif missing: lines.append("⚠️ Entwurf wartet auf Freigabe (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( "⚠️ Heute noch kein Artikel im Plan.\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📆 Nächste 3 Tage:") lines.extend(plan_lines) style_preview = _fetch_style_preview() if style_preview: lines.append(f"\n🎨 Schreibstil Auto-5vor8:") 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"📝 Entwurf wartet auf Freigabe\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"⚠️ Noch kein Artikel für heute!\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()