#!/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 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']) 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_cleanup_db(): """Wöchentliche DB-Bereinigung: alte Einträge löschen.""" import sqlite3, os db_path = os.environ.get('DB_PATH', '/data/fuenfvoracht.db') 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 # article_versions älter als 90 Tage 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 con.execute("VACUUM") con.commit() con.close() flog.info('db_cleanup', post_history_deleted=r1, versions_deleted=r2, articles_deleted=r3) 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}) # Wöchentliche DB-Bereinigung (Sonntags 03:00 Uhr) 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()