Files
bdbot/bot/handlers/scheduler.py
2026-01-28 15:53:27 +03:00

189 lines
8.5 KiB
Python

"""Async scheduler for daily birthday notifications."""
from telebot.async_telebot import AsyncTeleBot
from datetime import datetime, timedelta, date
from typing import Dict, List, Tuple
from collections import defaultdict
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from bot.database import get_db_session, User, Chat, UserChat
from bot.messages import format_birthday_greeting, format_multiple_birthdays_greetings
from bot.config import Config
from bot.handlers.command_handlers import get_birthdays_in_range
from bot.logger import get_logger
logger = get_logger(__name__)
async def send_birthday_notifications(bot: AsyncTeleBot) -> None:
"""Send birthday notifications to all chats for users with birthday today."""
logger.info("Running daily birthday notification job")
async for db in get_db_session():
try:
today = datetime.now().date()
day = today.day
month = today.month
logger.debug(f"Checking birthdays for {day:02d}.{month:02d}")
# Get all users with birthday today
result = await db.execute(
select(User).filter(
User.birthday_day == day,
User.birthday_month == month
)
)
users_with_birthday = result.scalars().all()
if not users_with_birthday:
logger.info(f"No birthdays found for {day:02d}.{month:02d}")
return
logger.info(f"Found {len(users_with_birthday)} user(s) with birthday today")
# Group users by chat: chat_id -> list of (first_name, theme, user_id)
chat_users: Dict[int, List[Tuple[str, str, int]]] = defaultdict(list)
# For each user, find all their chats
for user in users_with_birthday:
# Get all chats where user is a member
result = await db.execute(
select(Chat).join(UserChat).filter(
UserChat.user_id == user.user_id,
Chat.bot_is_admin == True
)
)
user_chats = result.scalars().all()
for chat in user_chats:
try:
# Check if user is still in chat
member = await bot.get_chat_member(chat.chat_id, user.user_id)
if member.status not in ['left', 'kicked']:
chat_users[chat.chat_id].append(
(user.first_name, user.preference_theme, user.user_id)
)
except Exception:
# User might have blocked bot or bot was removed from chat
pass
# Send greetings grouped by chat
for chat_id, users_data in chat_users.items():
try:
logger.info(f"Sending birthday greetings to chat {chat_id} for {len(users_data)} user(s)")
if len(users_data) == 1:
# Single user - use simple format
first_name, theme, user_id = users_data[0]
greeting = format_birthday_greeting(first_name, theme, user_id)
await bot.send_message(chat_id, greeting, parse_mode='HTML')
logger.debug(f"Sent single birthday greeting to chat {chat_id} for user {user_id}")
else:
# Multiple users - use special format
greeting = format_multiple_birthdays_greetings(users_data)
await bot.send_message(chat_id, greeting, parse_mode='HTML')
logger.debug(f"Sent multiple birthday greetings to chat {chat_id}")
except Exception as e:
# Handle errors: bot removed from chat, etc.
if hasattr(e, 'error_code') and e.error_code == 403:
logger.warning(f"Bot blocked or removed from chat {chat_id}, updating status")
# Bot was blocked or removed from chat
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if chat:
chat.bot_is_admin = False
await db.commit()
else:
logger.error(f"Error sending birthday greeting to chat {chat_id}: {e}", exc_info=True)
finally:
await db.close()
logger.info("Daily birthday notification job completed")
async def send_monthly_birthday_overview(bot: AsyncTeleBot) -> None:
"""Send monthly birthday overview (like /month command) to all chats on 1st of each month."""
logger.info("Running monthly birthday overview job")
sent_count = 0
async for db in get_db_session():
try:
today = datetime.now().date()
logger.debug(f"Sending monthly overview for date: {today}")
# Get all chats where bot is admin
result = await db.execute(select(Chat).filter(Chat.bot_is_admin == True))
admin_chats = result.scalars().all()
logger.info(f"Found {len(admin_chats)} admin chat(s) for monthly overview")
for chat in admin_chats:
try:
# Get birthdays for next 31 days
birthdays = await get_birthdays_in_range(db, chat.chat_id, today, days=31)
if not birthdays:
# Don't send message if no birthdays
logger.debug(f"No birthdays in next 31 days for chat {chat.chat_id}")
continue
# Format message (same as /month command)
message_text = "🎂 Дни рождения на ближайшие 31 день:\n\n"
for date_str, names in sorted(birthdays.items()):
names_list = ", ".join(names)
message_text += f"{date_str}: {names_list}\n"
await bot.send_message(chat.chat_id, message_text)
sent_count += 1
logger.info(f"Sent monthly overview to chat {chat.chat_id} ({len(birthdays)} dates)")
except Exception as e:
# Handle errors: bot removed from chat, etc.
if hasattr(e, 'error_code') and e.error_code == 403:
logger.warning(f"Bot blocked or removed from chat {chat.chat_id}, updating status")
# Bot was blocked or removed from chat
chat.bot_is_admin = False
await db.commit()
else:
logger.error(f"Error sending monthly overview to chat {chat.chat_id}: {e}", exc_info=True)
finally:
await db.close()
logger.info(f"Monthly birthday overview job completed. Sent to {sent_count} chat(s)")
def setup_scheduler(bot: AsyncTeleBot) -> AsyncIOScheduler:
"""Setup and start the scheduler for daily birthday notifications."""
# Parse notification time
time_str: str = Config.NOTIFICATION_TIME
try:
hour, minute = map(int, time_str.split(':'))
except (ValueError, AttributeError):
hour, minute = 9, 0 # Default to 9:00
# Get timezone
timezone_str: str = Config.TIMEZONE
try:
tz = pytz.timezone(timezone_str)
except (pytz.exceptions.UnknownTimeZoneError, AttributeError):
tz = pytz.UTC
# Create scheduler
scheduler = AsyncIOScheduler(timezone=tz)
# Add daily job
scheduler.add_job(
send_birthday_notifications,
trigger=CronTrigger(hour=hour, minute=minute),
args=[bot],
id='daily_birthday_notifications',
name='Send daily birthday notifications',
replace_existing=True
)
# Add monthly job (1st day of each month)
scheduler.add_job(
send_monthly_birthday_overview,
trigger=CronTrigger(day=1, hour=hour, minute=minute),
args=[bot],
id='monthly_birthday_overview',
name='Send monthly birthday overview',
replace_existing=True
)
return scheduler