job_reminder_afternoon prüfte nur channel.post_time (19:55), Artikel auf anderen Zeitslots (z.B. 19:45) wurden als fehlend gemeldet. Jetzt werden alle approved/scheduled Artikel des Tages geprüft. Made-with: Cursor
412 lines
16 KiB
Python
412 lines
16 KiB
Python
#!/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 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 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)
|
||
|
||
|
||
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():
|
||
"""Woechentliche DB-Bereinigung: alte Eintraege loeschen."""
|
||
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.execute("VACUUM")
|
||
con.commit()
|
||
con.close()
|
||
flog.info('db_cleanup', ph_del=r1, av_del=r2, art_del=r3)
|
||
|
||
|
||
async def job_reminder_afternoon(bot: Bot):
|
||
"""Nachmittags-Reminder wenn kein Artikel eingeplant ist."""
|
||
d = today_str()
|
||
articles_today = db.get_articles_by_date(d)
|
||
approved_today = [a for a in articles_today if a['status'] in ('approved', 'scheduled', 'sent_to_bot')]
|
||
if not approved_today:
|
||
await notify_reviewers(
|
||
bot,
|
||
f"⚠️ <b>Noch kein Artikel eingeplant!</b>\n\n"
|
||
f"Für heute ({d}) ist kein freigegebener Artikel vorhanden.\n"
|
||
f"👉 Dashboard: http://100.73.171.62:8080"
|
||
)
|
||
|
||
|
||
# ── 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})
|
||
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()
|