Merge branch 'v2_functions'
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-17 00:31:26 +09:00
36 changed files with 4396 additions and 352 deletions

View File

@@ -11,8 +11,9 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
"""Получить главную клавиатуру"""
buttons = [
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")]
[InlineKeyboardButton(text="🎰 Активные розыгрыши", callback_data="active_lotteries")],
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
[InlineKeyboardButton(text="❓ Справка", callback_data="help_main")]
]
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
@@ -22,7 +23,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
if is_admin:
buttons.extend([
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="admin_create_lottery")]
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="admin_create_lottery")]
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -30,13 +31,14 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
def get_admin_keyboard(self):
"""Получить админскую клавиатуру"""
buttons = [
[InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")],
[InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")],
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
[InlineKeyboardButton(text="💬 Сообщения пользователей", callback_data="admin_messages")],
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="🎰 Розыгрыши", callback_data="admin_lotteries"),
InlineKeyboardButton(text="🏆 Победители", callback_data="admin_winners")],
[InlineKeyboardButton(text="📋 Участники", callback_data="admin_participants"),
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_users")],
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -52,12 +54,12 @@ class MessageFormatterImpl(IMessageFormatter):
if is_admin:
buttons.extend([
[InlineKeyboardButton(text="🎲 Провести розыгрыш", callback_data=f"conduct_{lottery_id}")],
[InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"conduct_{lottery_id}")],
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_{lottery_id}")],
[InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_{lottery_id}")]
])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="active_lotteries")])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="active_lotteries")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
@@ -70,7 +72,7 @@ class MessageFormatterImpl(IMessageFormatter):
text = text[:47] + "..."
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="lottery_management")])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")])
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -34,6 +34,8 @@ class BotController(IBotController):
async def handle_start(self, message: Message):
"""Обработать команду /start"""
from src.utils.keyboards import get_main_reply_keyboard
user = await self.user_service.get_or_create_user(
telegram_id=message.from_user.id,
username=message.from_user.username,
@@ -49,14 +51,27 @@ class BotController(IBotController):
else:
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
keyboard = self.keyboard_builder.get_main_keyboard(
# Inline клавиатура
inline_keyboard = self.keyboard_builder.get_main_keyboard(
is_admin=self.is_admin(message.from_user.id),
is_registered=user.is_registered
)
# Обычная клавиатура
reply_keyboard = get_main_reply_keyboard(
is_admin=self.is_admin(message.from_user.id),
is_registered=user.is_registered
)
await message.answer(
welcome_text,
reply_markup=keyboard
reply_markup=reply_keyboard # Обычная клавиатура
)
# Отправляем inline клавиатуру отдельным сообщением
await message.answer(
"Выберите действие:",
reply_markup=inline_keyboard
)
async def handle_active_lotteries(self, callback: CallbackQuery):

View File

@@ -0,0 +1,176 @@
"""
Сервис для отслеживания активности пользователей
и автоматической блокировки неактивных
"""
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
import logging
from .models import User, BlockedUser
from .database import async_session_maker
logger = logging.getLogger(__name__)
class ActivityService:
"""Сервис для управления активностью пользователей"""
# Период неактивности в днях (по умолчанию 30 дней)
INACTIVITY_PERIOD_DAYS = 30
@staticmethod
async def update_user_activity(session: AsyncSession, telegram_id: int) -> None:
"""
Обновить last_activity для пользователя
Args:
session: Сессия БД
telegram_id: Telegram ID пользователя
"""
try:
stmt = (
update(User)
.where(User.telegram_id == telegram_id)
.values(last_activity=datetime.now(timezone.utc))
)
await session.execute(stmt)
await session.commit()
except Exception as e:
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
await session.rollback()
@staticmethod
async def get_inactive_users(
session: AsyncSession,
days: int = None
) -> List[User]:
"""
Получить список неактивных пользователей
Args:
session: Сессия БД
days: Количество дней неактивности (по умолчанию INACTIVITY_PERIOD_DAYS)
Returns:
Список неактивных пользователей
"""
if days is None:
days = ActivityService.INACTIVITY_PERIOD_DAYS
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
stmt = select(User).where(
and_(
User.last_activity < cutoff_date,
User.is_registered == True
)
)
result = await session.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def mark_inactive_users(session: AsyncSession, days: int = None) -> int:
"""
Пометить неактивных пользователей как заблокированных
Args:
session: Сессия БД
days: Количество дней неактивности
Returns:
Количество помеченных пользователей
"""
try:
inactive_users = await ActivityService.get_inactive_users(session, days)
marked_count = 0
for user in inactive_users:
# Проверяем, не помечен ли уже
stmt = select(BlockedUser).where(
and_(
BlockedUser.telegram_id == user.telegram_id,
BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True
)
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
# Создаем новую запись
blocked = BlockedUser(
telegram_id=user.telegram_id,
error_type='inactive',
error_message=f'User inactive for {days} days',
first_blocked_at=datetime.now(timezone.utc),
last_attempt_at=datetime.now(timezone.utc),
attempt_count=1,
is_active=True
)
session.add(blocked)
marked_count += 1
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
await session.commit()
return marked_count
except Exception as e:
logger.error(f"Ошибка при пометке неактивных пользователей: {e}")
await session.rollback()
return 0
@staticmethod
async def reactivate_user(session: AsyncSession, telegram_id: int) -> bool:
"""
Реактивировать пользователя (убрать из списка заблокированных по неактивности)
Args:
session: Сессия БД
telegram_id: Telegram ID пользователя
Returns:
True если пользователь реактивирован
"""
try:
# Обновляем активность
await ActivityService.update_user_activity(session, telegram_id)
# Деактивируем запись о блокировке по неактивности
stmt = (
update(BlockedUser)
.where(
and_(
BlockedUser.telegram_id == telegram_id,
BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True
)
)
.values(is_active=False)
)
await session.execute(stmt)
await session.commit()
logger.info(f"Пользователь {telegram_id} реактивирован")
return True
except Exception as e:
logger.error(f"Ошибка реактивации пользователя {telegram_id}: {e}")
await session.rollback()
return False
@staticmethod
async def check_and_mark_inactive_users() -> int:
"""
Проверить и пометить всех неактивных пользователей
Используется для периодического запуска
Returns:
Количество помеченных пользователей
"""
async with async_session_maker() as session:
marked = await ActivityService.mark_inactive_users(session)
logger.info(f"Проверка неактивных пользователей завершена. Помечено: {marked}")
return marked

View File

@@ -0,0 +1,495 @@
"""
Сервисы для системы рассылок с поддержкой Redis очередей
"""
import asyncio
import json
import logging
from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone
from aiogram import Bot
from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
from .config import REDIS_URL, ADMIN_IDS
from .database import async_session_maker
logger = logging.getLogger(__name__)
class RedisQueue:
"""Класс для работы с Redis очередями"""
def __init__(self, redis_url: str = REDIS_URL):
self.redis_url = redis_url
self._redis: Optional[redis.Redis] = None
async def connect(self):
"""Подключение к Redis"""
if self._redis is None:
self._redis = await redis.from_url(self.redis_url, decode_responses=False)
async def disconnect(self):
"""Отключение от Redis"""
if self._redis:
await self._redis.close()
self._redis = None
async def add_to_queue(self, queue_name: str, data: Dict) -> int:
"""
Добавить элемент в очередь
Args:
queue_name: Название очереди
data: Данные для добавления
Returns:
int: Длина очереди после добавления
"""
await self.connect()
serialized = json.dumps(data).encode('utf-8')
return await self._redis.rpush(queue_name, serialized)
async def get_from_queue(self, queue_name: str, timeout: int = 0) -> Optional[Dict]:
"""
Получить элемент из очереди (блокирующая операция)
Args:
queue_name: Название очереди
timeout: Таймаут ожидания в секундах (0 = бесконечно)
Returns:
Dict или None
"""
await self.connect()
result = await self._redis.blpop(queue_name, timeout=timeout)
if result:
_, data = result
return json.loads(data.decode('utf-8'))
return None
async def get_queue_length(self, queue_name: str) -> int:
"""Получить длину очереди"""
await self.connect()
return await self._redis.llen(queue_name)
async def clear_queue(self, queue_name: str):
"""Очистить очередь"""
await self.connect()
await self._redis.delete(queue_name)
class BroadcastService:
"""Сервис для управления рассылками"""
# Константы для очередей
QUEUE_BROADCAST = "broadcast_queue"
QUEUE_FAILED = "broadcast_failed_queue"
# Лимиты Telegram
BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
def __init__(self):
self.redis_queue = RedisQueue()
async def check_user_blocked(self, session: AsyncSession, telegram_id: int) -> bool:
"""
Проверить, заблокирован ли пользователь
Args:
session: Сессия БД
telegram_id: Telegram ID пользователя
Returns:
bool: True если заблокирован
"""
stmt = select(BlockedUser).where(
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def mark_user_blocked(
self,
session: AsyncSession,
telegram_id: int,
error_type: str,
error_message: str
):
"""
Отметить пользователя как заблокированного
Args:
session: Сессия БД
telegram_id: Telegram ID пользователя
error_type: Тип ошибки
error_message: Сообщение об ошибке
"""
# Проверяем, есть ли уже запись
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
result = await session.execute(stmt)
blocked_user = result.scalar_one_or_none()
if blocked_user:
# Обновляем существующую запись
blocked_user.error_type = error_type
blocked_user.error_message = error_message
blocked_user.last_attempt_at = datetime.now(timezone.utc)
blocked_user.attempt_count += 1
blocked_user.is_active = True
else:
# Создаем новую запись
blocked_user = BlockedUser(
telegram_id=telegram_id,
error_type=error_type,
error_message=error_message
)
session.add(blocked_user)
await session.commit()
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
async def unblock_user(self, session: AsyncSession, telegram_id: int):
"""
Разблокировать пользователя (если сообщение успешно доставлено)
Args:
session: Сессия БД
telegram_id: Telegram ID пользователя
"""
stmt = select(BlockedUser).where(
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True
)
result = await session.execute(stmt)
blocked_user = result.scalar_one_or_none()
if blocked_user:
blocked_user.is_active = False
await session.commit()
logger.info(f"Пользователь {telegram_id} разблокирован")
async def send_message_to_user(
self,
bot: Bot,
user: User,
message: Message
) -> Tuple[bool, Optional[str]]:
"""
Отправить сообщение пользователю с обработкой ошибок
Args:
bot: Инстанс бота
user: Объект пользователя
message: Сообщение для отправки
Returns:
Tuple[bool, Optional[str]]: (успех, тип_ошибки)
"""
try:
# Проверяем, не заблокирован ли пользователь
async with async_session_maker() as session:
is_blocked = await self.check_user_blocked(session, user.telegram_id)
if is_blocked:
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
return False, "blocked"
# Отправляем сообщение
if message.text:
await bot.send_message(
user.telegram_id,
message.text,
parse_mode="Markdown"
)
elif message.photo:
await bot.send_photo(
user.telegram_id,
photo=message.photo[-1].file_id,
caption=message.caption,
parse_mode="Markdown"
)
elif message.video:
await bot.send_video(
user.telegram_id,
video=message.video.file_id,
caption=message.caption,
parse_mode="Markdown"
)
elif message.document:
await bot.send_document(
user.telegram_id,
document=message.document.file_id,
caption=message.caption,
parse_mode="Markdown"
)
else:
# Копируем сообщение как есть
await message.copy_to(user.telegram_id)
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
async with async_session_maker() as session:
await self.unblock_user(session, user.telegram_id)
return True, None
except TelegramForbiddenError as e:
# Пользователь заблокировал бота
error_type = "blocked_bot"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramBadRequest as e:
# Пользователь удален или деактивирован
error_str = str(e).lower()
if "user is deactivated" in error_str:
error_type = "deactivated"
elif "user not found" in error_str:
error_type = "not_found"
elif "chat not found" in error_str:
error_type = "chat_not_found"
else:
error_type = "bad_request"
async with async_session_maker() as session:
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
return False, error_type
except TelegramRetryAfter as e:
# FloodWait - слишком много запросов
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
# Повторная попытка
return await self.send_message_to_user(bot, user, message)
except Exception as e:
# Другие ошибки
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
return False, "unknown_error"
async def broadcast_to_users(
self,
bot: Bot,
message: Message,
admin_id: int,
users: Optional[List[User]] = None
) -> Dict[str, Any]:
"""
Рассылка сообщений пользователям через Redis очередь
Args:
bot: Инстанс бота
message: Сообщение для рассылки
admin_id: ID администратора, который запустил рассылку
users: Список пользователей (если None - всем зарегистрированным)
Returns:
Dict: Статистика рассылки
"""
# Создаем лог рассылки
async with async_session_maker() as session:
broadcast_log = BroadcastLog(
broadcast_type='direct',
message_type=message.content_type,
message_text=message.text or message.caption,
file_id=self._get_file_id(message),
created_by=admin_id,
status='in_progress'
)
session.add(broadcast_log)
await session.commit()
await session.refresh(broadcast_log)
log_id = broadcast_log.id
# Получаем список пользователей
if users is None:
async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей
stmt = select(User).where(User.is_registered == True)
result = await session.execute(stmt)
all_users = result.scalars().all()
# Получаем список заблокированных пользователей
blocked_stmt = select(BlockedUser.telegram_id).where(
BlockedUser.is_active == True
)
blocked_result = await session.execute(blocked_stmt)
blocked_ids = set(row[0] for row in blocked_result.fetchall())
# Фильтруем пользователей, исключая заблокированных
users = [u for u in all_users if u.telegram_id not in blocked_ids]
total_users = len(users)
success_count = 0
failed_count = 0
blocked_count = 0
# Рассылаем пакетами
for i in range(0, total_users, self.BATCH_SIZE):
batch = users[i:i + self.BATCH_SIZE]
# Отправляем пакет
tasks = []
for user in batch:
tasks.append(self.send_message_to_user(bot, user, message))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Подсчитываем результаты
for result in results:
if isinstance(result, Exception):
failed_count += 1
elif result[0]: # success
success_count += 1
else:
failed_count += 1
if result[1] in ['blocked_bot', 'deactivated', 'not_found']:
blocked_count += 1
# Задержка между пакетами
if i + self.BATCH_SIZE < total_users:
await asyncio.sleep(self.BATCH_DELAY)
# Обновляем лог
async with async_session_maker() as session:
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
result = await session.execute(stmt)
broadcast_log = result.scalar_one()
broadcast_log.total_recipients = total_users
broadcast_log.success_count = success_count
broadcast_log.failed_count = failed_count
broadcast_log.blocked_count = blocked_count
broadcast_log.completed_at = datetime.now(timezone.utc)
broadcast_log.status = 'completed'
await session.commit()
return {
'total': total_users,
'success': success_count,
'failed': failed_count,
'blocked': blocked_count
}
async def broadcast_to_channel(
self,
bot: Bot,
message: Message,
channel_id: int,
admin_id: int
) -> bool:
"""
Отправка сообщения в канал
Args:
bot: Инстанс бота
message: Сообщение для отправки
channel_id: ID канала
admin_id: ID администратора
Returns:
bool: Успех операции
"""
# Создаем лог
async with async_session_maker() as session:
broadcast_log = BroadcastLog(
broadcast_type='channel',
target_id=channel_id,
message_type=message.content_type,
message_text=message.text or message.caption,
file_id=self._get_file_id(message),
created_by=admin_id,
total_recipients=1,
status='in_progress'
)
session.add(broadcast_log)
await session.commit()
await session.refresh(broadcast_log)
log_id = broadcast_log.id
try:
# Отправляем в канал
if message.text:
await bot.send_message(channel_id, message.text, parse_mode="Markdown")
elif message.photo:
await bot.send_photo(
channel_id,
photo=message.photo[-1].file_id,
caption=message.caption,
parse_mode="Markdown"
)
elif message.video:
await bot.send_video(
channel_id,
video=message.video.file_id,
caption=message.caption,
parse_mode="Markdown"
)
elif message.document:
await bot.send_document(
channel_id,
document=message.document.file_id,
caption=message.caption,
parse_mode="Markdown"
)
else:
await message.copy_to(channel_id)
# Обновляем лог
async with async_session_maker() as session:
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
result = await session.execute(stmt)
broadcast_log = result.scalar_one()
broadcast_log.success_count = 1
broadcast_log.completed_at = datetime.now(timezone.utc)
broadcast_log.status = 'completed'
await session.commit()
return True
except Exception as e:
logger.error(f"Ошибка отправки в канал {channel_id}: {e}")
# Обновляем лог
async with async_session_maker() as session:
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
result = await session.execute(stmt)
broadcast_log = result.scalar_one()
broadcast_log.failed_count = 1
broadcast_log.completed_at = datetime.now(timezone.utc)
broadcast_log.status = 'failed'
await session.commit()
return False
def _get_file_id(self, message: Message) -> Optional[str]:
"""Получить file_id из сообщения"""
if message.photo:
return message.photo[-1].file_id
elif message.video:
return message.video.file_id
elif message.document:
return message.document.file_id
elif message.animation:
return message.animation.file_id
elif message.voice:
return message.voice.file_id
elif message.audio:
return message.audio.file_id
return None
# Глобальный экземпляр сервиса
broadcast_service = BroadcastService()

View File

@@ -360,7 +360,16 @@ class ChatPermissionService:
if settings and settings.global_ban:
return False, "Чат временно закрыт администратором"
# Проверяем личный бан
# Проверяем is_chat_banned в модели User
from .models import User
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user and user.is_chat_banned:
return False, "Вы заблокированы и не можете отправлять сообщения в чат"
# Проверяем личный бан (старая система через BannedUser)
is_banned = await BanService.is_banned(session, telegram_id)
if is_banned:
return False, "Вы заблокированы и не можете отправлять сообщения"

View File

@@ -12,6 +12,9 @@ if not BOT_TOKEN:
# База данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
# Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# Администраторы
ADMIN_IDS = []
admin_ids_str = os.getenv("ADMIN_IDS", "")

View File

@@ -19,7 +19,9 @@ class User(Base):
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
is_admin = Column(Boolean, default=False)
is_chat_banned = Column(Boolean, default=False) # Заблокирован ли в чате бота
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_activity = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Последняя активность
# Секретный код для верификации выигрыша (генерируется при регистрации)
verification_code = Column(String(10), unique=True, nullable=True)
@@ -242,4 +244,72 @@ class P2PMessage(Base):
reply_to = relationship("P2PMessage", remote_side=[id], backref="replies")
def __repr__(self):
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
class BroadcastChannel(Base):
"""Каналы и группы для рассылки"""
__tablename__ = "broadcast_channels"
id = Column(Integer, primary_key=True)
chat_id = Column(BigInteger, nullable=False, unique=True, index=True) # ID канала или группы
chat_type = Column(String(20), nullable=False) # 'channel' или 'group'
title = Column(String(255), nullable=False) # Название
username = Column(String(255), nullable=True) # Username (если есть)
description = Column(Text, nullable=True) # Описание
is_active = Column(Boolean, default=True, index=True) # Активен ли для рассылок
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Связи
admin = relationship("User")
def __repr__(self):
return f"<BroadcastChannel(id={self.id}, title={self.title}, type={self.chat_type})>"
class BlockedUser(Base):
"""Пользователи, которые заблокировали бота или недоступны"""
__tablename__ = "blocked_users"
id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)
error_type = Column(String(100), nullable=False) # тип ошибки (blocked, deleted, deactivated, etc.)
error_message = Column(Text, nullable=True) # Полное сообщение об ошибке
first_blocked_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_attempt_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
attempt_count = Column(Integer, default=1) # Количество неудачных попыток
is_active = Column(Boolean, default=True, index=True) # Активна ли блокировка
def __repr__(self):
return f"<BlockedUser(telegram_id={self.telegram_id}, error={self.error_type})>"
class BroadcastLog(Base):
"""История рассылок"""
__tablename__ = "broadcast_logs"
id = Column(Integer, primary_key=True)
broadcast_type = Column(String(20), nullable=False, index=True) # 'direct', 'channel', 'group'
target_id = Column(BigInteger, nullable=True) # ID канала/группы (null для direct)
message_type = Column(String(20), nullable=False) # text, photo, video, etc.
message_text = Column(Text, nullable=True) # Текст сообщения
file_id = Column(String(255), nullable=True) # ID файла (если есть)
# Статистика
total_recipients = Column(Integer, default=0) # Всего получателей
success_count = Column(Integer, default=0) # Успешно доставлено
failed_count = Column(Integer, default=0) # Не доставлено
blocked_count = Column(Integer, default=0) # Заблокировали бота
# Метаданные
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
completed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(20), default='pending', index=True) # pending, in_progress, completed, failed
# Связи
admin = relationship("User")
def __repr__(self):
return f"<BroadcastLog(id={self.id}, type={self.broadcast_type}, status={self.status})>"

56
src/core/scheduler.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Планировщик фоновых задач для бота
"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import logging
from src.core.activity_service import ActivityService
logger = logging.getLogger(__name__)
class BotScheduler:
"""Планировщик задач для бота"""
def __init__(self):
self.scheduler = AsyncIOScheduler()
def setup_jobs(self):
"""Настройка всех периодических задач"""
# Проверка неактивных пользователей каждый день в 03:00
self.scheduler.add_job(
self._check_inactive_users,
trigger=CronTrigger(hour=3, minute=0),
id='check_inactive_users',
name='Проверка неактивных пользователей',
replace_existing=True
)
logger.info("Планировщик задач настроен")
async def _check_inactive_users(self):
"""Проверка и блокировка неактивных пользователей"""
try:
logger.info("Запуск проверки неактивных пользователей")
marked = await ActivityService.check_and_mark_inactive_users()
logger.info(f"Проверка завершена. Неактивных пользователей помечено: {marked}")
except Exception as e:
logger.error(f"Ошибка при проверке неактивных пользователей: {e}", exc_info=True)
def start(self):
"""Запуск планировщика"""
self.setup_jobs()
self.scheduler.start()
logger.info("Планировщик задач запущен")
def shutdown(self):
"""Остановка планировщика"""
if self.scheduler.running:
self.scheduler.shutdown()
logger.info("Планировщик задач остановлен")
# Глобальный экземпляр планировщика
bot_scheduler = BotScheduler()

257
src/core/user_management.py Normal file
View File

@@ -0,0 +1,257 @@
"""
Сервис управления пользователями с поиском и пагинацией
"""
from datetime import datetime, timezone
from sqlalchemy import select, or_, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any, Optional, Tuple
import logging
from .models import User
from .database import async_session_maker
logger = logging.getLogger(__name__)
class UserManagementService:
"""Сервис для управления пользователями"""
# Количество пользователей на странице
USERS_PER_PAGE = 15
@staticmethod
async def search_users(
session: AsyncSession,
query: str = None,
page: int = 1,
per_page: int = None,
filters: Dict[str, Any] = None
) -> Tuple[List[User], int]:
"""
Поиск пользователей с фильтрацией и пагинацией
Args:
session: Сессия БД
query: Поисковый запрос (ищет по username, имени, telegram_id, номеру карты)
page: Номер страницы (начиная с 1)
per_page: Количество на странице (по умолчанию USERS_PER_PAGE)
filters: Дополнительные фильтры:
- is_registered: bool
- is_admin: bool
- is_chat_banned: bool
Returns:
Tuple[List[User], int]: Список пользователей и общее количество
"""
if per_page is None:
per_page = UserManagementService.USERS_PER_PAGE
# Базовый запрос
stmt = select(User)
conditions = []
# Поисковый запрос
if query and query.strip():
query = query.strip()
search_conditions = []
# Поиск по username
if query.startswith('@'):
search_conditions.append(User.username.ilike(f'%{query[1:]}%'))
else:
# Поиск по всем полям
search_conditions.append(User.username.ilike(f'%{query}%'))
search_conditions.append(User.first_name.ilike(f'%{query}%'))
search_conditions.append(User.last_name.ilike(f'%{query}%'))
search_conditions.append(User.nickname.ilike(f'%{query}%'))
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
# Если запрос - число, ищем по telegram_id
if query.isdigit():
search_conditions.append(User.telegram_id == int(query))
conditions.append(or_(*search_conditions))
# Применяем фильтры
if filters:
if 'is_registered' in filters:
conditions.append(User.is_registered == filters['is_registered'])
if 'is_admin' in filters:
conditions.append(User.is_admin == filters['is_admin'])
if 'is_chat_banned' in filters:
conditions.append(User.is_chat_banned == filters['is_chat_banned'])
# Добавляем условия к запросу
if conditions:
stmt = stmt.where(and_(*conditions))
# Получаем общее количество
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await session.execute(count_stmt)
total = total_result.scalar()
# Применяем сортировку и пагинацию
stmt = stmt.order_by(User.created_at.desc())
offset = (page - 1) * per_page
stmt = stmt.limit(per_page).offset(offset)
# Выполняем запрос
result = await session.execute(stmt)
users = list(result.scalars().all())
return users, total
@staticmethod
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""Получить пользователя по ID"""
stmt = select(User).where(User.id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
"""Получить пользователя по Telegram ID"""
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def ban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
"""
Заблокировать пользователя в чате
Args:
session: Сессия БД
user_id: ID пользователя
Returns:
bool: Успех операции
"""
try:
user = await UserManagementService.get_user_by_id(session, user_id)
if not user:
return False
user.is_chat_banned = True
await session.commit()
logger.info(f"Пользователь {user.telegram_id} заблокирован в чате")
return True
except Exception as e:
logger.error(f"Ошибка блокировки пользователя {user_id} в чате: {e}")
await session.rollback()
return False
@staticmethod
async def unban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
"""
Разблокировать пользователя в чате
Args:
session: Сессия БД
user_id: ID пользователя
Returns:
bool: Успех операции
"""
try:
user = await UserManagementService.get_user_by_id(session, user_id)
if not user:
return False
user.is_chat_banned = False
await session.commit()
logger.info(f"Пользователь {user.telegram_id} разблокирован в чате")
return True
except Exception as e:
logger.error(f"Ошибка разблокировки пользователя {user_id} в чате: {e}")
await session.rollback()
return False
@staticmethod
async def get_user_stats(session: AsyncSession) -> Dict[str, int]:
"""
Получить статистику по пользователям
Returns:
Dict: Статистика
"""
# Общее количество
total_stmt = select(func.count(User.id))
total_result = await session.execute(total_stmt)
total = total_result.scalar()
# Зарегистрированные
registered_stmt = select(func.count(User.id)).where(User.is_registered == True)
registered_result = await session.execute(registered_stmt)
registered = registered_result.scalar()
# Админы
admin_stmt = select(func.count(User.id)).where(User.is_admin == True)
admin_result = await session.execute(admin_stmt)
admins = admin_result.scalar()
# Заблокированные в чате
banned_stmt = select(func.count(User.id)).where(User.is_chat_banned == True)
banned_result = await session.execute(banned_stmt)
banned = banned_result.scalar()
return {
'total': total,
'registered': registered,
'admins': admins,
'chat_banned': banned
}
@staticmethod
def format_user_info(user: User, detailed: bool = False) -> str:
"""
Форматировать информацию о пользователе для отображения
Args:
user: Пользователь
detailed: Детальная информация
Returns:
str: Форматированная информация
"""
# Базовая информация
info = f"👤 <b>{user.first_name}"
if user.last_name:
info += f" {user.last_name}"
info += "</b>"
if user.username:
info += f" (@{user.username})"
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
# Статусы
statuses = []
if user.is_admin:
statuses.append("👑 Админ")
if user.is_registered:
statuses.append("✅ Зарегистрирован")
if user.is_chat_banned:
statuses.append("🚫 Заблокирован в чате")
if statuses:
info += "\n" + " | ".join(statuses)
# Детальная информация
if detailed:
if user.nickname:
info += f"\n📝 Никнейм: {user.nickname}"
if user.club_card_number:
info += f"\n🎫 Клубная карта: <code>{user.club_card_number}</code>"
if user.phone:
info += f"\n📞 Телефон: <code>{user.phone}</code>"
# Даты
info += f"\n📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}"
if user.last_activity:
days_inactive = (datetime.now(timezone.utc) - user.last_activity).days
info += f"\n⏰ Последняя активность: {user.last_activity.strftime('%d.%m.%Y %H:%M')}"
if days_inactive > 0:
info += f" ({days_inactive} дн. назад)"
return info

1
src/filters/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Кастомные фильтры для бота"""

View File

@@ -0,0 +1,28 @@
"""Регистронезависимый фильтр команд"""
from aiogram.filters import Command
from typing import Union
class CaseInsensitiveCommand(Command):
"""
Регистронезависимый фильтр команд.
Обрабатывает команды независимо от регистра: /Start, /START, /start - все обрабатываются одинаково.
"""
def __init__(
self,
*commands: str,
prefix: str = "/",
ignore_mention: bool = False,
magic: Union[None, str] = None,
):
"""Инициализация с ignore_case=True для регистронезависимости"""
# Вызываем родительский конструктор с ignore_case=True
super().__init__(
*commands,
prefix=prefix,
ignore_case=True, # Включаем игнорирование регистра
ignore_mention=ignore_mention,
magic=magic
)

View File

@@ -6,6 +6,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, and_
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
from src.core.services import UserService, LotteryService, ParticipationService
@@ -22,19 +23,19 @@ class AddAccountStates(StatesGroup):
choosing_lottery = State()
@router.message(Command("cancel"))
@router.message(CaseInsensitiveCommand("cancel"))
@admin_only
async def cancel_command(message: Message, state: FSMContext):
"""Отменить текущую операцию и сбросить состояние"""
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
await state.clear()
await message.answer("✅ Состояние сброшено. Все операции отменены.")
@router.message(Command("add_account"))
@router.message(CaseInsensitiveCommand("add_account"))
@admin_only
async def add_account_command(message: Message, state: FSMContext):
"""
Добавить счет пользователю по клубной карте
Добавить счет пользователю по клубной карте (регистронезависимо)
Формат: /add_account <club_card> <account_number>
Или: /add_account (затем вводить данные построчно)
"""
@@ -434,11 +435,11 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
await state.clear()
@router.message(Command("remove_account"))
@router.message(CaseInsensitiveCommand("remove_account"))
@admin_only
async def remove_account_command(message: Message):
"""
Деактивировать счет(а)
Деактивировать счет(а) (регистронезависимо)
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
Можно указать несколько счетов через пробел для массового удаления
"""
@@ -504,11 +505,11 @@ async def remove_account_command(message: Message):
await message.answer(f"❌ Критическая ошибка: {str(e)}")
@router.message(Command("verify_winner"))
@router.message(CaseInsensitiveCommand("verify_winner"))
@admin_only
async def verify_winner_command(message: Message):
"""
Подтвердить выигрыш по коду верификации
Подтвердить выигрыш по коду верификации (регистронезависимо)
Формат: /verify_winner <verification_code> <lottery_id>
Пример: /verify_winner AB12CD34 1
"""
@@ -595,11 +596,11 @@ async def verify_winner_command(message: Message):
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("winner_status"))
@router.message(CaseInsensitiveCommand("winner_status"))
@admin_only
async def winner_status_command(message: Message):
"""
Показать статус всех победителей розыгрыша
Показать статус всех победителей розыгрыша (регистронезависимо)
Формат: /winner_status <lottery_id>
"""
@@ -668,11 +669,11 @@ async def winner_status_command(message: Message):
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("user_info"))
@router.message(CaseInsensitiveCommand("user_info"))
@admin_only
async def user_info_command(message: Message):
"""
Показать информацию о пользователе
Показать информацию о пользователе (регистронезависимо)
Формат: /user_info <club_card>
"""

View File

@@ -4,6 +4,7 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.chat_services import (
ChatSettingsService,
BanService,
@@ -29,10 +30,10 @@ def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
])
@router.message(Command("chat_mode"))
@router.message(CaseInsensitiveCommand("chat_mode"))
@admin_only
async def cmd_chat_mode(message: Message):
"""Команда управления режимом чата"""
"""Команда управления режимом чата (регистронезависимо)"""
async with async_session_maker() as session:
settings = await ChatSettingsService.get_or_create_settings(session)
@@ -68,10 +69,10 @@ async def process_chat_mode(callback: CallbackQuery):
await callback.answer("✅ Режим изменен")
@router.message(Command("set_forward"))
@router.message(CaseInsensitiveCommand("set_forward"))
@admin_only
async def cmd_set_forward(message: Message):
"""Установить ID канала для пересылки"""
"""Установить ID канала для пересылки (регистронезависимо)"""
args = message.text.split(maxsplit=1)
if len(args) < 2:
@@ -100,10 +101,10 @@ async def cmd_set_forward(message: Message):
)
@router.message(Command("global_ban"))
@router.message(CaseInsensitiveCommand("global_ban"))
@admin_only
async def cmd_global_ban(message: Message):
"""Включить/выключить глобальный бан чата"""
"""Включить/выключить глобальный бан чата (регистронезависимо)"""
async with async_session_maker() as session:
settings = await ChatSettingsService.get_or_create_settings(session)
@@ -126,10 +127,10 @@ async def cmd_global_ban(message: Message):
)
@router.message(Command("ban"))
@router.message(CaseInsensitiveCommand("ban"))
@admin_only
async def cmd_ban(message: Message):
"""Забанить пользователя"""
"""Забанить пользователя (регистронезависимо)"""
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
@@ -191,10 +192,10 @@ async def cmd_ban(message: Message):
)
@router.message(Command("unban"))
@router.message(CaseInsensitiveCommand("unban"))
@admin_only
async def cmd_unban(message: Message):
"""Разбанить пользователя"""
"""Разбанить пользователя (регистронезависимо)"""
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
@@ -232,10 +233,10 @@ async def cmd_unban(message: Message):
await message.answer("❌ Пользователь не был забанен")
@router.message(Command("banlist"))
@router.message(CaseInsensitiveCommand("banlist"))
@admin_only
async def cmd_banlist(message: Message):
"""Показать список забаненных пользователей"""
"""Показать список заблокированных пользователей (регистронезависимо)"""
async with async_session_maker() as session:
banned_users = await BanService.get_banned_users(session, active_only=True)
@@ -262,10 +263,10 @@ async def cmd_banlist(message: Message):
await message.answer(text, parse_mode="HTML")
@router.message(Command("delete_msg"))
@router.message(CaseInsensitiveCommand("delete_msg"))
@admin_only
async def cmd_delete_message(message: Message):
"""Удалить сообщение из чата (пометить как удаленное)"""
"""Удалить сообщение из чата (пометить как удаленное) (регистронезависимо)"""
if not message.reply_to_message:
await message.answer(
@@ -329,10 +330,10 @@ async def cmd_delete_message(message: Message):
await message.answer("Не удалось удалить сообщение")
@router.message(Command("chat_stats"))
@router.message(CaseInsensitiveCommand("chat_stats"))
@admin_only
async def cmd_chat_stats(message: Message):
"""Статистика чата"""
"""Статистика чата (регистронезависимо)"""
async with async_session_maker() as session:
settings = await ChatSettingsService.get_or_create_settings(session)

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter, Command
from src.filters.case_insensitive import CaseInsensitiveCommand
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from typing import List, Dict, Optional, Set, Any
@@ -43,9 +45,9 @@ def _contains_account_numbers(text: str) -> bool:
router = Router(name='chat_router')
@router.message(Command("chat"))
@router.message(CaseInsensitiveCommand("chat"))
async def enter_chat_command(message: Message, state: FSMContext):
"""Войти в режим чата через команду /chat"""
"""Войти в режим чата через команду /chat (регистронезависимо)"""
await enter_chat(message, state)
@@ -58,6 +60,8 @@ async def enter_chat_callback(callback: CallbackQuery, state: FSMContext):
async def enter_chat(message: Message, state: FSMContext):
"""Общая функция входа в чат"""
from src.utils.keyboards import get_chat_reply_keyboard
await state.set_state(ChatStates.in_chat)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
@@ -65,19 +69,28 @@ async def enter_chat(message: Message, state: FSMContext):
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
])
# Обычная клавиатура для чата
reply_keyboard = get_chat_reply_keyboard()
await message.answer(
"💬 <b>Вы вошли в режим чата</b>\n\n"
"Теперь все ваши сообщения будут рассылаться участникам.\n"
"Вы можете отправлять текст, фото, видео, документы и стикеры.\n\n"
"Для выхода нажмите кнопку ниже или отправьте /exit",
reply_markup=keyboard,
reply_markup=reply_keyboard, # Обычная клавиатура
parse_mode="HTML"
)
# Inline клавиатура отдельным сообщением
await message.answer(
"Выберите действие:",
reply_markup=keyboard
)
@router.message(Command("exit"), StateFilter(ChatStates.in_chat))
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
async def exit_chat_command(message: Message, state: FSMContext):
"""Выйти из режима чата через команду /exit"""
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
await exit_chat(message, state)
@@ -90,19 +103,71 @@ async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
async def exit_chat(message: Message, state: FSMContext):
"""Общая функция выхода из чата"""
from src.utils.keyboards import get_main_reply_keyboard
from src.core.config import ADMIN_IDS
from src.core.services import UserService
from src.core.database import async_session_maker
await state.clear()
# Получаем информацию о пользователе
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
is_registered = user.is_registered if user else False
is_admin_user = message.from_user.id in ADMIN_IDS
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
])
# Обычная клавиатура
reply_keyboard = get_main_reply_keyboard(is_admin=is_admin_user, is_registered=is_registered)
await message.answer(
"✅ <b>Вы вышли из режима чата</b>\n\n"
"Ваши сообщения больше не будут рассылаться.",
reply_markup=keyboard,
reply_markup=reply_keyboard, # Обычная клавиатура
parse_mode="HTML"
)
# Inline клавиатура отдельным сообщением
await message.answer(
"Выберите действие:",
reply_markup=keyboard
)
@router.message(StateFilter(ChatStates.in_chat), F.text)
async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата"""
text = message.text.strip().lower()
# Проверяем ключевые слова для выхода
exit_keywords = ['/start', 'start', 'старт', '/exit']
if text in exit_keywords:
if text in ['/start', 'start', 'старт']:
# Выходим из чата и показываем главное меню
await state.clear()
from src.components.ui import UserUI
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
await message.answer(
"🏠 <b>Главное меню</b>\n\n"
"Вы вышли из режима чата.",
reply_markup=keyboard,
parse_mode="HTML"
)
return # Не обрабатываем дальше
else:
# Для /exit просто выходим
await exit_chat(message, state)
return
# Если не ключевое слово, пропускаем дальше для обработки как обычное сообщение чата
# Остальная логика обработки сообщений чата будет ниже
# Настройки для планировщика рассылки

View File

@@ -0,0 +1,225 @@
"""Обработчики справки и помощи пользователям"""
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from src.core.config import ADMIN_IDS
from src.filters.case_insensitive import CaseInsensitiveCommand
def is_admin(user_id: int) -> bool:
"""Проверка является ли пользователь админом"""
return user_id in ADMIN_IDS
router = Router(name='help_router')
def get_help_menu_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура меню справки"""
buttons = [
[InlineKeyboardButton(text="📝 Регистрация", callback_data="help_registration")],
[InlineKeyboardButton(text="🎰 Участие в розыгрышах", callback_data="help_lottery")],
[InlineKeyboardButton(text="💬 Чат", callback_data="help_chat")],
[InlineKeyboardButton(text="⚙️ Команды", callback_data="help_commands")],
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_back_to_help_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура возврата к справке"""
buttons = [
[InlineKeyboardButton(text="◀️ Назад к справке", callback_data="help_main")],
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@router.message(CaseInsensitiveCommand("help"))
async def help_command(message: Message):
"""Показать справку по команде /help (регистронезависимо)"""
await show_help_main(message)
@router.callback_query(F.data == "help_main")
async def help_main_callback(callback: CallbackQuery):
"""Показать главное меню справки"""
await callback.answer()
await show_help_main(callback.message, edit=True)
async def show_help_main(message: Message, edit: bool = False):
"""Показать главное меню справки"""
text = (
"❓ <b>Справка по работе с ботом</b>\n\n"
"Выберите интересующий вас раздел:\n\n"
"📝 <b>Регистрация</b> - как зарегистрироваться в системе\n"
"🎰 <b>Участие в розыгрышах</b> - как участвовать и выигрывать\n"
"💬 <b>Чат</b> - общение с другими участниками\n"
"⚙️ <b>Команды</b> - список доступных команд"
)
keyboard = get_help_menu_keyboard()
if edit:
try:
await message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except:
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
else:
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
@router.callback_query(F.data == "help_registration")
async def help_registration(callback: CallbackQuery):
"""Справка по регистрации"""
await callback.answer()
text = (
"📝 <b>Регистрация в системе</b>\n\n"
"<b>Как зарегистрироваться:</b>\n\n"
"1⃣ Откройте главное меню и выберите <i>\"Регистрация\"</i>\n\n"
"2⃣ Введите ваши данные:\n"
" • Имя и фамилию\n"
" • Номер телефона\n"
" • Номер клубной карты (если есть)\n\n"
"3⃣ Ожидайте подтверждения от администратора\n\n"
"4⃣ После одобрения вам станут доступны все функции бота:\n"
" ✅ Участие в розыгрышах\n"
" ✅ Доступ к чату\n"
" ✅ Получение уведомлений\n\n"
"💡 <b>Важно!</b>\n"
"Указывайте корректные данные - они проверяются администратором.\n\n"
"Статус вашей регистрации можно проверить в главном меню."
)
keyboard = get_back_to_help_keyboard()
try:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except:
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
@router.callback_query(F.data == "help_lottery")
async def help_lottery(callback: CallbackQuery):
"""Справка по розыгрышам"""
await callback.answer()
text = (
"🎰 <b>Участие в розыгрышах</b>\n\n"
"<b>Как принять участие:</b>\n\n"
"1⃣ Убедитесь, что вы зарегистрированы\n\n"
"2⃣ Дождитесь объявления нового розыгрыша\n"
" • Уведомления приходят всем участникам\n"
" • Розыгрыши проводятся регулярно\n\n"
"3В описании розыгрыша будет указано:\n"
" 📝 Название и описание приза\n"
" 👥 Количество победителей\n"
" 📅 Дата и время проведения\n\n"
"4⃣ Когда придет время:\n"
" • Администратор проведет розыгрыш\n"
" • Победители определяются случайным образом\n"
" • Всем участникам придет уведомление о результатах\n\n"
"🏆 <b>Если вы выиграли:</b>\n"
" • Вы получите личное уведомление\n"
" • Информация о получении приза будет в сообщении\n"
" • Следуйте инструкциям администратора\n\n"
"💡 <b>Совет:</b> Включите уведомления бота, чтобы не пропустить розыгрыш!"
)
keyboard = get_back_to_help_keyboard()
try:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except:
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
@router.callback_query(F.data == "help_chat")
async def help_chat(callback: CallbackQuery):
"""Справка по чату"""
await callback.answer()
text = (
"💬 <b>Чат участников</b>\n\n"
"<b>Как пользоваться чатом:</b>\n\n"
"1⃣ <b>Вход в чат:</b>\n"
" • Откройте главное меню\n"
" • Выберите <i>\"Войти в чат\"</i>\n"
" • Или отправьте команду <code>/chat</code>\n\n"
"2⃣ <b>Отправка сообщений:</b>\n"
" • Пишите как обычно в Telegram\n"
" • Ваши сообщения увидят все участники\n"
" • Можно отправлять:\n"
" 📝 Текст\n"
" 🖼 Фото и видео\n"
" 📎 Документы\n"
" 😊 Стикеры\n\n"
"3⃣ <b>Выход из чата:</b>\n"
" • Нажмите кнопку <i>\"Выйти из чата\"</i>\n"
" • Или отправьте команду <code>/exit</code>\n"
" • Или напишите <code>старт</code> / <code>start</code> / <code>/start</code>\n\n"
"⚠️ <b>Правила чата:</b>\n"
" • Будьте вежливы с другими участниками\n"
"Не спамьте сообщениями\n"
" • Запрещены оскорбления и реклама\n"
" • Администратор может заблокировать за нарушения\n\n"
"💡 <b>Подсказка:</b>\n"
"Если вы отправляете 20+ сообщений, они рассылаются пакетами с небольшой задержкой."
)
keyboard = get_back_to_help_keyboard()
try:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except:
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
@router.callback_query(F.data == "help_commands")
async def help_commands(callback: CallbackQuery):
"""Справка по командам"""
await callback.answer()
user_id = callback.from_user.id
is_user_admin = is_admin(user_id)
text = (
"⚙️ <b>Список команд бота</b>\n\n"
"<b>Основные команды:</b>\n\n"
"🏠 <code>/start</code> - Главное меню\n"
"❓ <code>/help</code> - Справка (это меню)\n"
"💬 <code>/chat</code> - Войти в чат\n"
"🚪 <code>/exit</code> - Выйти из чата\n\n"
"<b>Как использовать:</b>\n\n"
"• Отправьте команду в чат с ботом\n"
"• Начните команду с символа /\n"
"• Можно также использовать кнопки в меню\n\n"
)
if is_user_admin:
text += (
"👑 <b>Команды администратора:</b>\n\n"
"🔧 <code>/admin</code> - Панель администратора\n"
"📊 Управление розыгрышами\n"
"👥 Управление пользователями\n"
"📢 Массовые рассылки\n"
"⚙️ Настройки системы\n\n"
)
text += (
"💡 <b>Полезные советы:</b>\n\n"
"• Включите уведомления для получения важных сообщений\n"
"• Используйте кнопки - это быстрее команд\n"
"В чате пишите <code>старт</code> чтобы вернуться в меню\n"
"• Регулярно проверяйте бота на наличие новых розыгрышей"
)
keyboard = get_back_to_help_keyboard()
try:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except:
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")

View File

@@ -6,6 +6,7 @@ from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand
from ..core.config import ADMIN_IDS
from ..core.database import async_session_maker
from ..core.chat_services import ChatMessageService
@@ -21,10 +22,10 @@ def is_admin(user_id: int) -> bool:
return user_id in ADMIN_IDS
@message_admin_router.message(Command("delete"))
@message_admin_router.message(CaseInsensitiveCommand("delete"))
async def delete_replied_message(message: Message):
"""
Удаление сообщения по команде /delete
Удаление сообщения по команде /delete (регистронезависимо)
Работает только если команда является ответом на сообщение бота
"""
if not is_admin(message.from_user.id):

View File

@@ -2,6 +2,8 @@
from aiogram import Router, F
from aiogram.filters import Command, StateFilter
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,10 +30,10 @@ def is_admin(user_id: int) -> bool:
return user_id in ADMIN_IDS
@router.message(Command("chat"))
@router.message(CaseInsensitiveCommand("chat"))
async def show_chat_menu(message: Message, state: FSMContext):
"""
Главное меню чата
Главное меню чата (регистронезависимо)
/chat - показать меню с опциями общения
"""
# Очищаем состояние при входе в меню (выход из диалога)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select, and_
from datetime import datetime, timezone, timedelta
import random
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService
from src.core.services import LotteryService
@@ -17,11 +18,11 @@ from src.core.permissions import admin_only
router = Router()
@router.message(Command("check_unclaimed"))
@router.message(CaseInsensitiveCommand("check_unclaimed"))
@admin_only
async def check_unclaimed_winners(message: Message):
"""
Проверить неподтвержденные выигрыши (более 24 часов)
Проверить неподтвержденные выигрыши (более 24 часов) (регистронезависимо)
Формат: /check_unclaimed <lottery_id>
"""
@@ -118,11 +119,11 @@ async def check_unclaimed_winners(message: Message):
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("redraw"))
@router.message(CaseInsensitiveCommand("redraw"))
@admin_only
async def redraw_lottery(message: Message):
"""
Переиграть розыгрыш для неподтвержденных выигрышей
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
Формат: /redraw <lottery_id>
"""

View File

@@ -2,6 +2,8 @@
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter
from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
import logging

View File

@@ -7,6 +7,7 @@ from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.config import ADMIN_IDS
from src.core.permissions import is_admin
@@ -14,9 +15,9 @@ from src.core.permissions import is_admin
test_router = Router()
@test_router.message(Command("test_start"))
@test_router.message(CaseInsensitiveCommand("test_start"))
async def cmd_test_start(message: Message):
"""Тестовая команда /test_start"""
"""Тестовая команда /test_start (регистронезависимо)"""
user_id = message.from_user.id
first_name = message.from_user.first_name
is_admin_user = is_admin(user_id)
@@ -47,9 +48,9 @@ async def cmd_test_start(message: Message):
)
@test_router.message(Command("test_admin"))
@test_router.message(CaseInsensitiveCommand("test_admin"))
async def cmd_test_admin(message: Message):
"""Тестовая команда /test_admin"""
"""Тестовая команда /test_admin (регистронезависимо)"""
if not is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды")
return

View File

@@ -0,0 +1,6 @@
"""
Middleware для бота
"""
from .activity import ActivityMiddleware
__all__ = ['ActivityMiddleware']

View File

@@ -0,0 +1,52 @@
"""
Middleware для отслеживания активности пользователей
"""
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
import logging
from src.core.database import async_session_maker
from src.core.activity_service import ActivityService
logger = logging.getLogger(__name__)
class ActivityMiddleware(BaseMiddleware):
"""Middleware для обновления last_activity при каждом взаимодействии"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
# Получаем telegram_id из события
telegram_id = None
if isinstance(event, Message):
telegram_id = event.from_user.id if event.from_user else None
elif isinstance(event, CallbackQuery):
telegram_id = event.from_user.id if event.from_user else None
elif isinstance(event, Update):
if event.message and event.message.from_user:
telegram_id = event.message.from_user.id
elif event.callback_query and event.callback_query.from_user:
telegram_id = event.callback_query.from_user.id
# Обновляем активность если есть telegram_id
if telegram_id:
try:
async with async_session_maker() as session:
# Обновляем активность
await ActivityService.update_user_activity(session, telegram_id)
# Проверяем, не был ли пользователь заблокирован за неактивность
# Если был - реактивируем
await ActivityService.reactivate_user(session, telegram_id)
except Exception as e:
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
# Вызываем следующий обработчик
return await handler(event, data)

80
src/utils/keyboards.py Normal file
View File

@@ -0,0 +1,80 @@
"""Вспомогательные функции для создания клавиатур"""
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
def get_main_reply_keyboard(is_admin: bool = False, is_registered: bool = False) -> ReplyKeyboardMarkup:
"""
Получить главную обычную клавиатуру с командами
Args:
is_admin: Является ли пользователь администратором
is_registered: Зарегистрирован ли пользователь
Returns:
ReplyKeyboardMarkup с кнопками команд
"""
keyboard = []
# Первая строка - основные команды
row1 = [
KeyboardButton(text="🎰 Розыгрыши"),
KeyboardButton(text="💬 Чат")
]
keyboard.append(row1)
# Вторая строка - дополнительные команды
row2 = []
if not is_admin and not is_registered:
row2.append(KeyboardButton(text="📝 Регистрация"))
if is_registered or is_admin:
row2.append(KeyboardButton(text="🔑 Мой код"))
row2.append(KeyboardButton(text="💳 Мои счета"))
if row2:
keyboard.append(row2)
# Третья строка - справка
row3 = [KeyboardButton(text="❓ Справка")]
# Админские команды
if is_admin:
row3.append(KeyboardButton(text="⚙️ Админ панель"))
keyboard.append(row3)
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Выберите действие..."
)
def get_chat_reply_keyboard() -> ReplyKeyboardMarkup:
"""
Получить клавиатуру для режима чата
Returns:
ReplyKeyboardMarkup с кнопками управления чатом
"""
keyboard = [
[KeyboardButton(text="🚪 Выйти из чата")],
[KeyboardButton(text="🏠 Главное меню")]
]
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Напишите сообщение или выберите действие..."
)
def remove_keyboard() -> ReplyKeyboardMarkup:
"""
Убрать обычную клавиатуру
Returns:
ReplyKeyboardMarkup с параметром remove_keyboard=True
"""
from aiogram.types import ReplyKeyboardRemove
return ReplyKeyboardRemove()