refactor
This commit is contained in:
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Bot package
|
||||
42
bot/bot.py
Normal file
42
bot/bot.py
Normal 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
33
bot/config.py
Normal 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
98
bot/database.py
Normal 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
1
bot/handlers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Handlers package
|
||||
199
bot/handlers/command_handlers.py
Normal file
199
bot/handlers/command_handlers.py
Normal 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
|
||||
228
bot/handlers/group_handlers.py
Normal file
228
bot/handlers/group_handlers.py
Normal 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()
|
||||
365
bot/handlers/private_handlers.py
Normal file
365
bot/handlers/private_handlers.py
Normal 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
188
bot/handlers/scheduler.py
Normal 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
47
bot/logger.py
Normal 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
42
bot/main.py
Normal 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
187
bot/messages.py
Normal 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)
|
||||
Reference in New Issue
Block a user