homelab-brain/fuenfvoracht/src/bot.py
root eb6ca9e3d2 fix(fuenfvoracht): NoneType-Crash in handle_message beheben
Bot crashte mit AttributeError wenn effective_user None war
(z.B. bei automatischen Kanal-Nachrichten ohne Absender).
2026-03-11 19:05:57 +07:00

423 lines
17 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
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(
"🕗 <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'])
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"📤 <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 zur Bot-Benachrichtigung fällig sind."""
due = db.get_due_notifications()
for article in due:
d = article['date']
pt = article['post_time']
text = (
f"📋 <b>Review: {d} · {pt} Uhr</b>\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"☀️ <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]}")
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📆 <b>Nächste 3 Tage:</b>")
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"⚠️ <b>Noch nicht freigegeben!</b>\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()