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/__init__.py Normal file
View File

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

42
bot/bot.py Normal file
View File

@ -0,0 +1,42 @@
"""Main async bot module."""
from telebot.async_telebot import AsyncTeleBot
from bot.config import Config
from bot.database import init_db
from bot.handlers.group_handlers import register_group_handlers
from bot.handlers.private_handlers import register_private_handlers
from bot.handlers.command_handlers import register_command_handlers
from bot.logger import get_logger
logger = get_logger(__name__)
async def create_bot() -> AsyncTeleBot:
"""Create and configure the bot."""
logger.info("Creating bot instance...")
# Validate configuration
Config.validate()
logger.debug("Configuration validated")
# Ensure BOT_TOKEN is not None
if not Config.BOT_TOKEN:
logger.error("BOT_TOKEN is required but not set")
raise ValueError("BOT_TOKEN is required")
# Initialize database
logger.info("Initializing database...")
await init_db()
logger.info("Database initialized")
# Create bot instance
bot = AsyncTeleBot(Config.BOT_TOKEN)
logger.info("Bot instance created")
# Register all handlers
logger.debug("Registering handlers...")
register_group_handlers(bot)
register_private_handlers(bot)
register_command_handlers(bot)
logger.info("All handlers registered")
return bot

33
bot/config.py Normal file
View File

@ -0,0 +1,33 @@
"""Configuration module for loading environment variables."""
import os
from typing import Optional
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config:
"""Application configuration."""
# Telegram Bot Token
BOT_TOKEN: Optional[str] = os.getenv("BOT_TOKEN")
if not BOT_TOKEN:
raise ValueError("BOT_TOKEN environment variable is required")
# Database configuration
DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is required")
# Notification settings
NOTIFICATION_TIME: str = os.getenv("NOTIFICATION_TIME", "09:00") or "09:00"
TIMEZONE: str = os.getenv("TIMEZONE", "UTC") or "UTC"
@classmethod
def validate(cls) -> None:
"""Validate that all required configuration is present."""
if not cls.BOT_TOKEN:
raise ValueError("BOT_TOKEN is required")
if not cls.DATABASE_URL:
raise ValueError("DATABASE_URL is required")

98
bot/database.py Normal file
View File

@ -0,0 +1,98 @@
"""Database models and session management."""
from typing import AsyncGenerator
from sqlalchemy import create_engine, BigInteger, String, Integer, Boolean, ForeignKey, Column
from sqlalchemy.orm import declarative_base, sessionmaker, relationship, Session
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from bot.config import Config
from bot.logger import get_logger
logger = get_logger(__name__)
Base = declarative_base()
class User(Base):
"""User model - stores user information and birthday."""
__tablename__ = "users"
user_id = Column(BigInteger, primary_key=True)
username = Column(String, nullable=True)
first_name = Column(String, nullable=False)
birthday_day = Column(Integer, nullable=False)
birthday_month = Column(Integer, nullable=False)
birthday_year = Column(Integer, nullable=True)
preference_theme = Column(String, nullable=False)
# Relationships
chats = relationship("UserChat", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(user_id={self.user_id}, first_name={self.first_name})>"
class Chat(Base):
"""Chat model - stores chat information."""
__tablename__ = "chats"
chat_id = Column(BigInteger, primary_key=True)
chat_title = Column(String, nullable=False)
bot_is_admin = Column(Boolean, default=False, nullable=False)
# Relationships
users = relationship("UserChat", back_populates="chat", cascade="all, delete-orphan")
def __repr__(self):
return f"<Chat(chat_id={self.chat_id}, chat_title={self.chat_title})>"
class UserChat(Base):
"""Many-to-many relationship between users and chats."""
__tablename__ = "user_chats"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
chat_id = Column(BigInteger, ForeignKey("chats.chat_id"), primary_key=True)
# Relationships
user = relationship("User", back_populates="chats")
chat = relationship("Chat", back_populates="users")
__table_args__ = (
UniqueConstraint("user_id", "chat_id", name="unique_user_chat"),
)
def __repr__(self):
return f"<UserChat(user_id={self.user_id}, chat_id={self.chat_id})>"
# Database engine and session (async)
# Convert postgresql:// to postgresql+asyncpg://
async_database_url = Config.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
async_engine = create_async_engine(async_database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Get async database session."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db() -> None:
"""Initialize database - create all tables."""
logger.info("Initializing database...")
try:
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database initialized successfully")
except Exception as e:
logger.error(f"Error initializing database: {e}", exc_info=True)
raise
def get_db_session():
"""Get an async database session generator."""
return get_db()

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

47
bot/logger.py Normal file
View File

@ -0,0 +1,47 @@
"""Logging configuration for the bot."""
import logging
import sys
from typing import Optional
def setup_logging(level: Optional[str] = None) -> None:
"""Setup logging configuration for the bot.
Args:
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
If None, defaults to INFO
"""
log_level = getattr(logging, level.upper() if level else "INFO", logging.INFO)
# Create formatter
formatter = logging.Formatter(
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Create console handler (for Docker stdout/stderr)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
root_logger.addHandler(console_handler)
# Set specific loggers
logging.getLogger("telebot").setLevel(logging.WARNING) # Reduce telebot verbosity
logging.getLogger("apscheduler").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) # Reduce SQLAlchemy verbosity
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for a module.
Args:
name: Logger name (usually __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)

42
bot/main.py Normal file
View File

@ -0,0 +1,42 @@
"""Main entry point for the async bot."""
import asyncio
import os
from bot.bot import create_bot
from bot.handlers.scheduler import setup_scheduler
from bot.logger import setup_logging, get_logger
# Setup logging
log_level = os.getenv("LOG_LEVEL", "INFO")
setup_logging(log_level)
logger = get_logger(__name__)
async def main() -> None:
"""Main function to start the bot and scheduler."""
logger.info("Starting bot...")
try:
# Create bot
bot = await create_bot()
logger.info("Bot created successfully")
# Setup scheduler
scheduler = setup_scheduler(bot)
logger.info("Scheduler configured")
# Start scheduler
scheduler.start()
logger.info("Scheduler started")
# Start bot polling
logger.info("Starting bot polling...")
await bot.infinity_polling(none_stop=True)
except KeyboardInterrupt:
logger.info("Bot stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
raise
if __name__ == "__main__":
asyncio.run(main())

187
bot/messages.py Normal file
View File

@ -0,0 +1,187 @@
"""Themed birthday messages dictionaries."""
import random
# Themes list
THEMES = [
"Автомобили",
"Спорт",
"Танцы",
"Музыка",
"Аниме",
"Игры",
"Книги",
"Кино",
"Путешествия",
"Еда",
"Технологии",
"Искусство",
]
# Emoji dictionary for each theme
THEME_EMOJIS = {
"Автомобили": "🚗",
"Спорт": "",
"Танцы": "💃",
"Музыка": "🎵",
"Аниме": "🦊",
"Игры": "🎮",
"Книги": "📚",
"Кино": "🎬",
"Путешествия": "✈️",
"Еда": "🍕",
"Технологии": "💻",
"Искусство": "🎨",
}
def get_theme_emoji(theme: str) -> str:
"""Get emoji for the given theme."""
return THEME_EMOJIS.get(theme, "🎉") # Default emoji
# Birthday greeting opening phrases (random variations)
BIRTHDAY_GREETINGS = [
"С днем рождения",
"Поздравляю с днем рождения",
"С твоим днем рождения",
"Поздравляю тебя с днем рождения",
"Поздравляю с днем рождения",
]
def get_birthday_greeting_opening() -> str:
"""Get a random birthday greeting opening phrase."""
return random.choice(BIRTHDAY_GREETINGS)
# Birthday messages for each theme
BIRTHDAY_MESSAGES = {
"Автомобили": [
"желаю тебе купить бугатти!",
"пусть твой стальной конь всегда заводится с первого раза!",
"желаю тебе обновить свой автопарк!",
"чтобы у тебя проблем с машиной не было никогда!",
],
"Спорт": [
"желаю тебе новых спортивных достижений!",
"пусть каждый день будет полон энергии!",
"желаю тебе побить все личные рекорды!",
"пусть здоровье будет крепким, а дух сильным!",
"желаю достичь своих целей!",
],
"Танцы": [
"желаю тебе танцевать до упаду!",
"пусть каждый танец будет особенным!",
"желаю тебе найти свой ритм!",
"пусть музыка всегда вдохновляет тебя!",
"пусть жизнь будет грациозной, как и твои танцы!",
"встречай свой праздник в танце!",
],
"Музыка": [
"пусть каждый день звучит как любимый альбом!",
"желаю дальше двигаться по жизни только с ритмом!",
"желаю эпичных плейлистов под каждый случай!",
"желаю чтобы концерт твоих любимых исполнителей был неподалеку!"
],
"Аниме": [
"пусть жизнь будет как вечереника из Grand Blue!",
"встречай свой день рождения в 24 кадра!",
"качественных дабов от любимых дабберов тебе!",
"желаю тебе продолжения любимых тайтлов!",
"пусть твоя жизнь будет лучше финала Атаки Титанов!",
"желаю встречать новый день под любимый опенинг!",
],
"Игры": [
"чтобы твой вишлист никогда не пустовал!",
"с тебя платина в этом году!",
"тиммейтов в пачку, да бабок тачку!",
"чтоб раскиды на мираже залетали с первого раза!",
"чтобы хук стоял и на байбек всегда было!!",
"желаю пройти тайтлы, которые ты откладывал в долгий ящик!",
],
"Книги": [
"пусть каждая книга открывает новые миры!",
"желаю тебе найти свою книгу года!",
"пусть чтение всегда приносит удовольствие!",
"желаю тебе открыть новых любимых авторов!",
],
"Кино": [
"пусть каждый фильм оставляет яркие впечатления!",
"пусть кино всегда вдохновляет тебя!",
"желаю тебе открыть новых любимых режиссеров!",
"побольше попкорна, поменьше некачественных дабов!",
"пусть твое знание фильмов впечатляет и дальше!"
],
"Путешествия": [
"пусть каждое путешествие будет незабываемым!",
"желаю тебе открыть новые страны и города!",
"пусть дороги ведут к интересным местам!",
"пусть каждый отпуск будет полон приключений!",
],
"Еда": [
"желаю тебе открыть новые вкусы!",
"пусть кулинарные эксперименты всегда удаются!",
"желаю тебе найти свое идеальное блюдо!",
"пусть каждый ресторан удивляет тебя!",
],
"Технологии": [
"пусть код компилируется с первого раза!",
"пусть баги обходят тебя стороной!",
"пусть каждый проект приносит успех!",
"поменьше отвалов, побольше DDR5!",
],
"Искусство": [
"желаю тебе вдохновения для новых творений!",
"пусть каждый арт-проект будет особенным!",
"желаю тебе найти свой уникальный стиль!",
"желаю тебе открыть новые художественные горизонты!",
],
}
def get_birthday_message(theme: str) -> str:
"""Get a random birthday message for the given theme."""
if theme not in BIRTHDAY_MESSAGES:
theme = "Музыка" # Default theme
messages = BIRTHDAY_MESSAGES.get(theme, BIRTHDAY_MESSAGES["Музыка"])
return random.choice(messages)
def format_birthday_greeting(first_name: str, theme: str, user_id: int) -> str:
"""Format a complete birthday greeting with emoji and user link."""
emoji = get_theme_emoji(theme)
greeting_opening = get_birthday_greeting_opening()
message = get_birthday_message(theme)
# Format: party popper + greeting + theme emoji + bold name with link
user_link = f"tg://user?id={user_id}"
return f"🎉 {greeting_opening}, {emoji} <a href=\"{user_link}\"><b>{first_name}</b></a>, {message}"
def format_multiple_birthdays_greetings(users_data: list[tuple[str, str, int]]) -> str:
"""Format greetings for multiple users celebrating birthday today.
Args:
users_data: List of tuples (first_name, theme, user_id)
Returns:
Formatted message with all greetings
"""
count = len(users_data)
# Determine correct form of "человек"
if count == 1:
person_word = "человек"
elif count in [2, 3, 4]:
person_word = "человека"
else:
person_word = "человек"
header = f"🎉 Сегодня день рождения отмечают {count} {person_word}:\n\n"
greetings = []
for first_name, theme, user_id in users_data:
emoji = get_theme_emoji(theme)
greeting_opening = get_birthday_greeting_opening()
message = get_birthday_message(theme)
user_link = f"tg://user?id={user_id}"
greeting = f"{emoji} <a href=\"{user_link}\"><b>{first_name}</b></a> — {greeting_opening.lower()}, {message}"
greetings.append(greeting)
return header + "\n\n".join(greetings)