This commit is contained in:
2026-01-28 15:53:27 +03:00
parent a2b525f997
commit 8499142e22
24 changed files with 1685 additions and 1040 deletions

1
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1 @@
# Handlers package

View File

@ -0,0 +1,199 @@
"""Async handlers for group commands."""
from telebot.async_telebot import AsyncTeleBot
from telebot import types
from datetime import datetime, timedelta, date
from typing import Dict, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from bot.database import get_db_session, User, Chat, UserChat
from bot.logger import get_logger
from collections import defaultdict
logger = get_logger(__name__)
def register_command_handlers(bot: AsyncTeleBot) -> None:
"""Register all command handlers."""
@bot.message_handler(commands=['stats'], chat_types=['group', 'supergroup'])
async def handle_stats(message: types.Message) -> None:
"""Handle /stats command - show statistics."""
chat_id = message.chat.id
user_id = message.from_user.id if message.from_user else None
logger.info(f"Command /stats from user {user_id} in chat {chat_id}")
async for db in get_db_session():
try:
# Check if bot is admin
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if not chat or not chat.bot_is_admin:
logger.warning(f"/stats command denied - bot not admin in chat {chat_id}")
await bot.reply_to(message, "Мне нужны права администратора для выполнения этой команды.")
return
# Get total members count (exclude bots)
try:
total_members_raw = await bot.get_chat_member_count(chat_id)
# Try to subtract all bots (including this bot) using admin list
human_members = total_members_raw
try:
admins = await bot.get_chat_administrators(chat_id)
bots_in_admins = sum(1 for m in admins if getattr(m.user, "is_bot", False))
human_members = max(total_members_raw - bots_in_admins, 0)
except Exception:
human_members = total_members_raw
total_members = human_members
except Exception:
total_members = 0
# Get users who shared birthday
result = await db.execute(
select(func.count(User.user_id.distinct())).select_from(User).join(UserChat).filter(
UserChat.chat_id == chat_id
)
)
users_with_birthday = result.scalar() or 0
users_without_birthday = max(total_members - users_with_birthday, 0)
# Format message
if total_members > 0:
percentage = (users_with_birthday / total_members) * 100
stats_text = (
f"📊 Статистика чата:\n\n"
f"Всего участников: {total_members}\n"
f"• Поделились днем рождения: {users_with_birthday}\n"
f"Не поделились: {users_without_birthday}\n"
f"• Процент: {percentage:.1f}%"
)
else:
stats_text = (
f"📊 Статистика чата:\n\n"
f"• Поделились днем рождения: {users_with_birthday}"
)
await bot.reply_to(message, stats_text)
finally:
await db.close()
@bot.message_handler(commands=['week'], chat_types=['group', 'supergroup'])
async def handle_week(message: types.Message) -> None:
"""Handle /week command - show birthdays for next 7 days."""
chat_id = message.chat.id
user_id = message.from_user.id if message.from_user else None
logger.info(f"Command /week from user {user_id} in chat {chat_id}")
async for db in get_db_session():
try:
# Check if bot is admin
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if not chat or not chat.bot_is_admin:
logger.warning(f"/week command denied - bot not admin in chat {chat_id}")
await bot.reply_to(message, "Мне нужны права администратора для выполнения этой команды.")
return
# Get birthdays for next 7 days
today = datetime.now().date()
birthdays = await get_birthdays_in_range(db, chat_id, today, days=7)
if not birthdays:
await bot.reply_to(message, "На ближайшие 7 дней дней рождений не запланировано.")
return
# Format message
message_text = "🎂 Дни рождения на ближайшие 7 дней:\n\n"
for date_str, names in sorted(birthdays.items()):
names_list = ", ".join(names)
message_text += f"{date_str}: {names_list}\n"
await bot.reply_to(message, message_text)
finally:
await db.close()
@bot.message_handler(commands=['month'], chat_types=['group', 'supergroup'])
async def handle_month(message: types.Message) -> None:
"""Handle /month command - show birthdays for next 31 days."""
chat_id = message.chat.id
user_id = message.from_user.id if message.from_user else None
logger.info(f"Command /month from user {user_id} in chat {chat_id}")
async for db in get_db_session():
try:
# Check if bot is admin
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if not chat or not chat.bot_is_admin:
logger.warning(f"/month command denied - bot not admin in chat {chat_id}")
await bot.reply_to(message, "Мне нужны права администратора для выполнения этой команды.")
return
# Get birthdays for next 31 days
today = datetime.now().date()
birthdays = await get_birthdays_in_range(db, chat_id, today, days=31)
if not birthdays:
await bot.reply_to(message, "На ближайшие 31 день дней рождений не запланировано.")
return
# Format message
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.reply_to(message, message_text)
finally:
await db.close()
@bot.message_handler(commands=['help'], chat_types=['group', 'supergroup'])
async def handle_help(message: types.Message) -> None:
"""Handle /help command - show help message."""
help_text = (
"📚 Команды бота:\n\n"
"/stats - Показать статистику: сколько человек поделились днем рождения\n"
"/week - Показать дни рождения на ближайшие 7 дней\n"
"/month - Показать дни рождения на ближайшие 31 день\n"
"/help - Показать это сообщение\n\n"
"Чтобы поделиться своим днем рождения, напиши боту в личку /start\n\n"
"from olly & cursor with <3"
)
await bot.reply_to(message, help_text)
async def get_birthdays_in_range(db: AsyncSession, chat_id: int, start_date: date, days: int) -> Dict[str, List[str]]:
"""Get birthdays in the specified date range for users in the chat."""
birthdays = defaultdict(list)
# Get all users in this chat
result = await db.execute(
select(User).join(UserChat).filter(UserChat.chat_id == chat_id).distinct()
)
users = result.scalars().all()
end_date = start_date + timedelta(days=days)
for user in users:
# Create birthday date for current year
try:
birthday_this_year = datetime(start_date.year, user.birthday_month, user.birthday_day).date()
except ValueError:
# Invalid date (e.g., Feb 29 in non-leap year)
continue
# Check if birthday falls in range
if start_date <= birthday_this_year < end_date:
date_str = f"{user.birthday_day:02d}.{user.birthday_month:02d}"
birthdays[date_str].append(user.first_name)
else:
# Check next year if we're near year end
try:
birthday_next_year = datetime(start_date.year + 1, user.birthday_month, user.birthday_day).date()
if start_date <= birthday_next_year < end_date:
date_str = f"{user.birthday_day:02d}.{user.birthday_month:02d}"
birthdays[date_str].append(user.first_name)
except ValueError:
pass
return birthdays

View File

@ -0,0 +1,228 @@
"""Async handlers for group events."""
from telebot.async_telebot import AsyncTeleBot
from telebot import types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from bot.database import get_db_session, User, Chat, UserChat
from bot.logger import get_logger
logger = get_logger(__name__)
def register_group_handlers(bot: AsyncTeleBot) -> None:
"""Register all group event handlers."""
@bot.message_handler(content_types=['new_chat_members'])
async def handle_new_member(message: types.Message) -> None:
"""Handle bot being added to a chat."""
# Check if bot was added
bot_me = await bot.get_me()
if not bot_me:
return
for member in message.new_chat_members:
if member.id == bot_me.id:
chat_id = message.chat.id
chat_title: str = message.chat.title or "Unknown"
logger.info(f"Bot added to chat: {chat_title} (ID: {chat_id})")
async for db in get_db_session():
try:
# Check if chat exists
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if not chat:
chat = Chat(chat_id=chat_id, chat_title=chat_title, bot_is_admin=False)
db.add(chat)
await db.commit()
logger.info(f"Created new chat record: {chat_title} (ID: {chat_id})")
# Check if bot is admin
try:
bot_me = await bot.get_me()
if not bot_me:
return
bot_member = await bot.get_chat_member(chat_id, bot_me.id)
is_admin = bot_member.status in ['administrator', 'creator']
chat.bot_is_admin = is_admin
await db.commit()
if not is_admin:
logger.warning(f"Bot is not admin in chat: {chat_title} (ID: {chat_id})")
# Request admin rights
await bot.reply_to(
message,
"Привет! Мне нужны права администратора, чтобы видеть список участников чата. "
"Пожалуйста, выдайте мне права администратора."
)
else:
logger.info(f"Bot is admin in chat: {chat_title} (ID: {chat_id})")
# Sync users and show statistics
await sync_chat_users(bot, db, chat_id)
await show_statistics(bot, message.chat)
except Exception as e:
logger.error(f"Error checking admin status in chat {chat_id}: {e}", exc_info=True)
# Bot might not have permission to check
chat.bot_is_admin = False
await db.commit()
await bot.reply_to(
message,
"Привет! Мне нужны права администратора, чтобы видеть список участников чата. "
"Пожалуйста, выдайте мне права администратора."
)
finally:
await db.close()
break
# NOTE:
# Updates about the bot itself (когда бота повышают до админа / понижают)
# приходят в поле `my_chat_member`, а не `chat_member`.
# Для их обработки в pyTelegramBotAPI нужно использовать my_chat_member_handler.
@bot.my_chat_member_handler()
async def handle_chat_member_update(message: types.ChatMemberUpdated) -> None:
"""Handle my_chat_member updates (bot role changes, e.g. becoming admin)."""
bot_me = await bot.get_me()
if not bot_me:
return
if message.new_chat_member.user.id == bot_me.id:
# Bot's status changed
chat_id = message.chat.id
chat_title: str = message.chat.title or "Unknown"
new_status = message.new_chat_member.status
logger.info(f"Bot status changed in chat {chat_title} (ID: {chat_id}): {new_status}")
async for db in get_db_session():
try:
result = await db.execute(select(Chat).filter(Chat.chat_id == chat_id))
chat = result.scalar_one_or_none()
if not chat:
chat = Chat(chat_id=chat_id, chat_title=chat_title, bot_is_admin=False)
db.add(chat)
# Check if bot became admin
is_admin = message.new_chat_member.status in ['administrator', 'creator']
was_admin = chat.bot_is_admin if chat else False
if chat:
chat.bot_is_admin = is_admin
chat.chat_title = chat_title
await db.commit()
if is_admin and not was_admin:
logger.info(f"Bot promoted to admin in chat: {chat_title} (ID: {chat_id})")
# Bot just became admin - sync users and show statistics
await sync_chat_users(bot, db, chat_id)
await show_statistics(bot, message.chat)
elif not is_admin and was_admin:
logger.warning(f"Bot demoted from admin in chat: {chat_title} (ID: {chat_id})")
finally:
await db.close()
async def sync_chat_users(bot: AsyncTeleBot, db: AsyncSession, chat_id: int) -> None:
"""Sync users from chat with database."""
try:
logger.debug(f"Syncing users for chat ID: {chat_id}")
# Get all users who already shared birthday
result = await db.execute(select(User))
existing_users = result.scalars().all()
logger.debug(f"Found {len(existing_users)} users with birthdays to sync")
synced_count = 0
# Try to check if they're in this chat and add relationships
for user in existing_users:
try:
member = await bot.get_chat_member(chat_id, user.user_id)
if member.status not in ['left', 'kicked']:
# User is in chat - ensure relationship exists
user_chat_result = await db.execute(
select(UserChat).filter(
UserChat.user_id == user.user_id,
UserChat.chat_id == chat_id
)
)
user_chat = user_chat_result.scalar_one_or_none()
if not user_chat:
user_chat = UserChat(user_id=user.user_id, chat_id=chat_id)
db.add(user_chat)
synced_count += 1
except Exception as e:
# User might have blocked bot or is not in chat
logger.debug(f"Could not sync user {user.user_id} in chat {chat_id}: {e}")
await db.commit()
logger.info(f"Synced {synced_count} users to chat ID: {chat_id}")
except Exception as e:
logger.error(f"Error syncing users for chat {chat_id}: {e}", exc_info=True)
# If sync fails, continue anyway
pass
async def show_statistics(bot: AsyncTeleBot, chat: types.Chat) -> None:
"""Show statistics about users who shared their birthday."""
async for db in get_db_session():
try:
chat_id = chat.id
logger.debug(f"Showing statistics for chat ID: {chat_id}")
# Get all chat members (exclude bots where possible)
try:
members_count_raw = await bot.get_chat_member_count(chat_id)
# Try to subtract all bots (including this bot) using admin list
human_members = members_count_raw
try:
admins = await bot.get_chat_administrators(chat_id)
bots_in_admins = sum(1 for m in admins if getattr(m.user, "is_bot", False))
human_members = max(members_count_raw - bots_in_admins, 0)
except Exception:
human_members = members_count_raw
members_count = human_members
except Exception as e:
logger.warning(f"Could not get member count for chat {chat_id}: {e}")
members_count = 0
# Get users from this chat who shared birthday
from sqlalchemy import func
result = await db.execute(
select(func.count(User.user_id.distinct())).select_from(User).join(UserChat).filter(
UserChat.chat_id == chat_id
)
)
users_with_birthday = result.scalar() or 0
logger.info(f"Chat {chat_id} statistics: {users_with_birthday} users with birthdays out of {members_count} total")
# Create message
if members_count > 0:
percentage = (users_with_birthday / members_count) * 100
message_text = (
f"Отлично! Теперь я админ этого чата.\n\n"
f"📊 Статистика:\n"
f"• Поделились днем рождения: {users_with_birthday} из {members_count} участников\n"
f"• Процент: {percentage:.1f}%"
)
else:
message_text = (
f"Отлично! Теперь я админ этого чата.\n\n"
f"📊 Поделились днем рождения: {users_with_birthday} участников"
)
# Create inline keyboard with button to start private chat
bot_me = await bot.get_me()
if not bot_me or not bot_me.username:
await bot.send_message(chat_id, message_text)
return
keyboard = types.InlineKeyboardMarkup()
start_button = types.InlineKeyboardButton(
text="Поделиться днем рождения",
url=f"https://t.me/{bot_me.username}?start=share_birthday"
)
keyboard.add(start_button)
await bot.send_message(chat_id, message_text, reply_markup=keyboard)
except Exception as e:
logger.error(f"Error showing statistics for chat {chat_id}: {e}", exc_info=True)
finally:
await db.close()

View File

@ -0,0 +1,365 @@
"""Async handlers for private messages."""
import re
from typing import Optional, Dict
from telebot.async_telebot import AsyncTeleBot
from telebot import types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from bot.database import get_db_session, User, Chat, UserChat
from bot.messages import THEMES, get_theme_emoji
from bot.logger import get_logger
logger = get_logger(__name__)
# User states for conversation flow
user_states: Dict[int, str] = {}
def register_private_handlers(bot: AsyncTeleBot) -> None:
"""Register all private message handlers."""
@bot.message_handler(commands=['start'], chat_types=['private'])
async def handle_start(message: types.Message) -> None:
"""Handle /start command in private chat."""
if not message.from_user:
return
user_id = message.from_user.id
username: Optional[str] = message.from_user.username
first_name: str = message.from_user.first_name or "Пользователь"
logger.info(f"User {user_id} ({first_name}) started conversation")
async for db in get_db_session():
try:
# Check if user exists
result = await db.execute(select(User).filter(User.user_id == user_id))
user = result.scalar_one_or_none()
if user:
# User already exists - ask if they want to update
logger.debug(f"User {user_id} already exists in database")
await bot.send_message(
user_id,
f"Привет, {first_name}! Ты уже поделился со мной днем рождения.\n"
f"Используй /update, чтобы обновить свои данные."
)
else:
# New user - ask for birthday
logger.info(f"New user {user_id} ({first_name}) - requesting birthday")
user_states[user_id] = 'waiting_birthday'
await bot.send_message(
user_id,
f"Привет, {first_name}! 👋\n\n"
f"Пришли мне свой день рождения в формате ДД.ММ или ДД.ММ.ГГГГ\n"
f"Например: 15.03 или 15.03.1990"
)
except Exception as e:
logger.error(f"Error handling /start for user {user_id}: {e}", exc_info=True)
finally:
await db.close()
@bot.message_handler(commands=['update'], chat_types=['private'])
async def handle_update(message: types.Message) -> None:
"""Handle /update command in private chat."""
if not message.from_user:
return
user_id = message.from_user.id
first_name: str = message.from_user.first_name or "Пользователь"
logger.info(f"User {user_id} ({first_name}) requested update")
async for db in get_db_session():
try:
result = await db.execute(select(User).filter(User.user_id == user_id))
user = result.scalar_one_or_none()
if user:
user_states[user_id] = 'waiting_birthday'
await bot.send_message(
user_id,
f"Хорошо, {first_name}! Пришли мне свой день рождения в формате ДД.ММ или ДД.ММ.ГГГГ"
)
else:
await bot.send_message(
user_id,
"Ты еще не поделился со мной днем рождения. Используй /start для начала."
)
except Exception as e:
logger.error(f"Error handling /update for user {user_id}: {e}", exc_info=True)
finally:
await db.close()
@bot.message_handler(commands=['help'], chat_types=['private'])
async def handle_help(message: types.Message) -> None:
"""Handle /help command in private chat."""
help_text = (
"📚 Команды бота:\n\n"
"/start - Поделиться днем рождения и выбрать тему предпочтений\n"
"/update - Обновить свой день рождения или тему предпочтений\n"
"/help - Показать это сообщение\n\n"
"В группах доступны команды:\n"
"/stats - Статистика по чату\n"
"/week - Дни рождения на 7 дней\n"
"/month - Дни рождения на 31 день\n\n"
"from olly & cursor with <3"
)
await bot.send_message(message.chat.id, help_text)
@bot.message_handler(func=lambda m: m.chat.type == 'private' and m.from_user and m.from_user.id in user_states and m.text)
async def handle_birthday_input(message: types.Message) -> None:
"""Handle birthday input from user."""
if not message.from_user or not message.text:
return
user_id = message.from_user.id
state = user_states.get(user_id)
if state == 'waiting_birthday':
# Parse birthday
birthday_text = message.text.strip()
parsed = parse_birthday(birthday_text)
if not parsed:
await bot.send_message(
user_id,
"Неверный формат даты. Используй формат ДД.ММ или ДД.ММ.ГГГГ\n"
"Например: 15.03 или 15.03.1990"
)
return
day, month, year = parsed
# Validate date
if not is_valid_date(day, month, year):
await bot.send_message(
user_id,
"Неверная дата. Проверь правильность дня и месяца."
)
return
# Save birthday and ask for preference
async for db in get_db_session():
try:
if not message.from_user:
return
username: Optional[str] = message.from_user.username
first_name: str = message.from_user.first_name or "Пользователь"
result = await db.execute(select(User).filter(User.user_id == user_id))
user = result.scalar_one_or_none()
if user:
# Update existing user
logger.info(f"Updating birthday for user {user_id}: {day:02d}.{month:02d}.{year or 'N/A'}")
user.birthday_day = day
user.birthday_month = month
user.birthday_year = year
user.first_name = first_name
user.username = username
else:
# Create new user
logger.info(f"Creating new user {user_id} with birthday: {day:02d}.{month:02d}.{year or 'N/A'}")
user = User(
user_id=user_id,
username=username,
first_name=first_name,
birthday_day=day,
birthday_month=month,
birthday_year=year,
preference_theme="Музыка" # Default theme
)
db.add(user)
await db.commit()
# Ask for preference theme
user_states[user_id] = 'waiting_preference'
await ask_preference_theme(bot, user_id)
except Exception as e:
logger.error(f"Error saving birthday for user {user_id}: {e}", exc_info=True)
finally:
await db.close()
elif state == 'waiting_preference':
# User selected preference theme
if not message.text:
return
theme_text = message.text.strip()
if theme_text not in THEMES:
await bot.send_message(
user_id,
"Пожалуйста, выбери одну из предложенных тем, нажав на кнопку."
)
return
# Save preference
async for db in get_db_session():
try:
result = await db.execute(select(User).filter(User.user_id == user_id))
user = result.scalar_one_or_none()
if user:
logger.info(f"User {user_id} selected theme: {theme_text}")
user.preference_theme = theme_text
await db.commit()
# Update user in all chats
await update_user_in_chats(bot, db, user)
await bot.send_message(
user_id,
f"Отлично! Я запомнил твои предпочтения: {theme_text}\n\n"
f"Теперь я буду поздравлять тебя с днем рождения во всех чатах, где ты состоишь!"
)
user_states.pop(user_id, None)
except Exception as e:
logger.error(f"Error saving preference for user {user_id}: {e}", exc_info=True)
finally:
await db.close()
@bot.callback_query_handler(func=lambda call: call.data and call.data.startswith('theme_'))
async def handle_theme_selection(call: types.CallbackQuery) -> None:
"""Handle theme selection from inline keyboard."""
if not call.from_user or not call.data or not call.message:
return
user_id = call.from_user.id
if user_states.get(user_id) != 'waiting_preference':
await bot.answer_callback_query(call.id, "Это действие недоступно сейчас.")
return
theme = call.data.replace('theme_', '')
if theme not in THEMES:
await bot.answer_callback_query(call.id, "Неверная тема.")
return
# Save preference
async for db in get_db_session():
try:
result = await db.execute(select(User).filter(User.user_id == user_id))
user = result.scalar_one_or_none()
if user:
logger.info(f"User {user_id} selected theme via callback: {theme}")
user.preference_theme = theme
await db.commit()
# Update user in all chats
await update_user_in_chats(bot, db, user)
await bot.answer_callback_query(call.id, f"Выбрано: {theme}")
await bot.edit_message_text(
f"Отлично! Я запомнил твои предпочтения: {theme}\n\n"
f"Теперь я буду поздравлять тебя с днем рождения во всех чатах, где ты состоишь!",
chat_id=call.message.chat.id,
message_id=call.message.message_id
)
user_states.pop(user_id, None)
except Exception as e:
logger.error(f"Error saving preference via callback for user {user_id}: {e}", exc_info=True)
finally:
await db.close()
def parse_birthday(text: str) -> Optional[tuple[int, int, Optional[int]]]:
"""Parse birthday from text. Returns (day, month, year) or None."""
# Try DD.MM.YYYY format
match = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', text)
if match:
day, month, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
return (day, month, year)
# Try DD.MM format
match = re.match(r'^(\d{1,2})\.(\d{1,2})$', text)
if match:
day, month = int(match.group(1)), int(match.group(2))
return (day, month, None)
return None
def is_valid_date(day: int, month: int, year: Optional[int]) -> bool:
"""Check if date is valid."""
if month < 1 or month > 12:
return False
if day < 1 or day > 31:
return False
# Check specific month limits
if month in [4, 6, 9, 11] and day > 30:
return False
if month == 2:
if year:
# Check leap year
if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0):
max_day = 29
else:
max_day = 28
else:
max_day = 29 # Assume leap year if year not provided
if day > max_day:
return False
return True
async def ask_preference_theme(bot: AsyncTeleBot, user_id: int) -> None:
"""Ask user to select preference theme."""
keyboard = types.InlineKeyboardMarkup(row_width=2)
# Show hobbies (themes) in 2 buttons per row
for i in range(0, len(THEMES), 2):
theme1 = THEMES[i]
emoji1 = get_theme_emoji(theme1)
btn1 = types.InlineKeyboardButton(
text=f"{emoji1} {theme1}",
callback_data=f'theme_{theme1}'
)
# Optional second button in the same row
if i + 1 < len(THEMES):
theme2 = THEMES[i + 1]
emoji2 = get_theme_emoji(theme2)
btn2 = types.InlineKeyboardButton(
text=f"{emoji2} {theme2}",
callback_data=f'theme_{theme2}'
)
keyboard.add(btn1, btn2)
else:
keyboard.add(btn1)
await bot.send_message(
user_id,
"Что тебе нравится? Выбери одну из тем:",
reply_markup=keyboard
)
async def update_user_in_chats(bot: AsyncTeleBot, db: AsyncSession, user: User) -> None:
"""Update user information in all chats where bot is admin."""
# Get all chats where bot is admin
result = await db.execute(select(Chat).filter(Chat.bot_is_admin == True))
admin_chats = result.scalars().all()
for chat in admin_chats:
try:
# Check if user is member of this chat
member = await bot.get_chat_member(chat.chat_id, user.user_id)
if member.status not in ['left', 'kicked']:
# User is in chat - add/update relationship
user_chat_result = await db.execute(
select(UserChat).filter(
UserChat.user_id == user.user_id,
UserChat.chat_id == chat.chat_id
)
)
user_chat = user_chat_result.scalar_one_or_none()
if not user_chat:
user_chat = UserChat(user_id=user.user_id, chat_id=chat.chat_id)
db.add(user_chat)
await db.commit()
except Exception:
# User might have blocked bot or bot was removed from chat
pass

188
bot/handlers/scheduler.py Normal file
View File

@ -0,0 +1,188 @@
"""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