Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

138
main.py
View File

@@ -9,12 +9,16 @@ from contextlib import asynccontextmanager
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.fsm.context import FSMContext
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.config import BOT_TOKEN
from src.core.config import BOT_TOKEN, REDIS_URL, FSM_TTL_SECONDS, LOG_LEVEL
from src.core.access import is_admin, is_staff
from src.middlewares.access import AccessMiddleware
from src.core.database import async_session_maker
from src.core.scheduler import bot_scheduler
from src.container import container
@@ -34,18 +38,32 @@ from src.handlers.admin_emoji_handlers import router as admin_emoji_router
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
level=LOG_LEVEL,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Создание бота и диспетчера
bot = Bot(token=BOT_TOKEN)
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
bot = Bot(token=BOT_TOKEN, session=AiohttpSession(timeout=30, limit=100))
if REDIS_URL:
storage = RedisStorage.from_url(
REDIS_URL, key_builder=DefaultKeyBuilder(with_bot_id=True),
state_ttl=FSM_TTL_SECONDS, data_ttl=FSM_TTL_SECONDS,
)
isolation = storage.create_isolation(lock_kwargs={"timeout": 3600})
else:
storage = MemoryStorage()
isolation = SimpleEventIsolation()
dp = Dispatcher(storage=storage, events_isolation=isolation)
router = Router()
@router.message(CaseInsensitiveCommand("cancel"))
async def cancel_dialog(message: Message, state: FSMContext):
await state.clear()
await message.answer("Операция отменена. /start — главное меню, /cashier — касса.")
# Middleware для логирования всех callback'ов
@dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data):
@@ -68,8 +86,9 @@ async def get_controller():
# === COMMAND HANDLERS ===
@router.message(CaseInsensitiveCommand("start"))
async def cmd_start(message: Message):
async def cmd_start(message: Message, state: FSMContext):
"""Обработчик команды /start (регистронезависимо)"""
await state.clear()
async with get_controller() as controller:
await controller.handle_start(message)
@@ -81,7 +100,7 @@ async def btn_lotteries(message: Message):
"""Обработчик кнопки 'Розыгрыши'"""
from src.core.database import async_session_maker
from src.repositories.implementations import LotteryRepository, ParticipationRepository
from src.display.message_formatter import MessageFormatterImpl
from src.components.ui import MessageFormatterImpl
from src.components.ui import KeyboardBuilderImpl
from src.core.services import UserService
from src.core.config import ADMIN_IDS
@@ -101,11 +120,11 @@ async def btn_lotteries(message: Message):
for lottery in lotteries:
participants_count = await participation_repo.get_count_by_lottery(lottery.id)
lottery_info = formatter.format_lottery_info(lottery, participants_count)
text += lottery_info + "\n" + "="*30 + "\n\n"
await message.answer(lottery_info, parse_mode="HTML")
# Получаем информацию о регистрации пользователя
user_service = UserService(session)
user = await user_service.get_or_create_user(
user = await UserService.get_or_create_user(
session,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
@@ -114,7 +133,7 @@ async def btn_lotteries(message: Message):
keyboard_builder = KeyboardBuilderImpl()
keyboard = keyboard_builder.get_main_keyboard(
is_admin=message.from_user.id in ADMIN_IDS,
is_admin=await is_admin(message.from_user.id),
is_registered=user.is_registered
)
@@ -134,30 +153,9 @@ async def btn_chat(message: Message, state: FSMContext):
@router.message(F.text == "📝 Регистрация")
async def btn_registration(message: Message, state: FSMContext):
"""Обработчик кнопки 'Регистрация'"""
from src.handlers.registration_handlers import RegistrationStates
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from src.handlers.registration_handlers import begin_registration
await begin_registration(message, state)
logger.info(f"User {message.from_user.id} pressed Registration button")
text = (
"📝 Регистрация в системе\n\n"
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
"Шаг 1 из 3: Придумайте никнейм\n\n"
"🎭 Введите ваш никнейм для чата:\n"
"• От 2 до 20 символов\n"
"• Может содержать буквы, цифры, пробелы\n"
"• Это имя будут видеть другие участники"
)
await message.answer(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
])
)
await state.set_state(RegistrationStates.waiting_for_nickname)
@router.message(CaseInsensitiveCommand("register"))
@@ -179,7 +177,7 @@ async def btn_my_code(message: Message):
await show_verification_code(message)
@router.message(F.text == "<EFBFBD> Мои логины")
@router.message(F.text.in_({"📱 Мои логины", "💳 Мои логины", "<EFBFBD> Мои логины"}))
async def btn_my_accounts(message: Message):
"""Обработчик кнопки 'Мои логины'"""
from src.handlers.registration_handlers import show_user_accounts
@@ -207,9 +205,9 @@ async def btn_exit_chat(message: Message, state: FSMContext):
@router.message(F.text == "🏠 Главная")
async def btn_main_menu(message: Message):
async def btn_main_menu(message: Message, state: FSMContext):
"""Обработчик кнопки 'Главная'"""
await cmd_start(message)
await cmd_start(message, state)
@router.message(CaseInsensitiveCommand("admin"))
@@ -260,10 +258,12 @@ async def active_lotteries_handler(callback: CallbackQuery):
@router.callback_query(F.data == "back_to_main")
async def back_to_main_handler(callback: CallbackQuery):
async def back_to_main_handler(callback: CallbackQuery, state: FSMContext):
"""Обработчик возврата в главное меню"""
await state.clear()
await callback.answer()
async with get_controller() as controller:
await controller.handle_start(callback.message)
await controller.handle_start(callback.message, actor=callback.from_user)
# Функции обрабатываются в:
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
# - registration_handlers.py: регистрация пользователей
@@ -276,17 +276,38 @@ async def back_to_main_handler(callback: CallbackQuery):
# Обработка неизвестных callback и сообщений происходит в соответствующих роутерах
async def main():
def configure_dispatcher():
"""Главная функция запуска бота"""
logger.info("Запуск бота...")
from src.middlewares.runtime import PrivateDialogMiddleware, ErrorMiddleware
for observer in (dp.message, dp.callback_query):
observer.outer_middleware(PrivateDialogMiddleware())
observer.outer_middleware(ErrorMiddleware())
# Подключаем middleware для отслеживания активности
dp.message.middleware(ActivityMiddleware())
dp.callback_query.middleware(ActivityMiddleware())
# Enforce access on every selected handler, including stale FSM callbacks.
from src.middlewares.access import ChatAccessMiddleware
p2p_chat_router.message.middleware(ChatAccessMiddleware())
p2p_chat_router.callback_query.middleware(ChatAccessMiddleware())
for protected in (admin_router, admin_chat_router, redraw_router, message_admin_router, admin_emoji_router):
protected.message.middleware(AccessMiddleware())
protected.callback_query.middleware(AccessMiddleware(
public_handlers={"confirm_winner_callback"} if protected is redraw_router else (),
))
for protected in (admin_account_router,):
protected.message.middleware(AccessMiddleware(allow_cashier=True))
protected.callback_query.middleware(AccessMiddleware(
allow_cashier=True, public_handlers={"view_my_accounts_callback"},
))
# Подключаем роутеры в правильном порядке
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
dp.include_router(router)
from src.handlers.cashier_handlers import cashier_router
dp.include_router(cashier_router)
# 2. Специфичные роутеры
dp.include_router(message_admin_router) # Управление сообщениями администратором
@@ -305,20 +326,46 @@ async def main():
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
async def main():
configure_dispatcher()
# Запускаем планировщик задач
bot_scheduler.start()
logger.info("Планировщик задач запущен")
# Запускаем polling
try:
from src.core.health import HEARTBEAT, heartbeat
HEARTBEAT.unlink(missing_ok=True)
if REDIS_URL:
await storage.redis.ping()
from sqlalchemy import select
from src.core.models import User
async with async_session_maker() as session:
await session.execute(select(User).limit(0))
await bot.get_me()
health_task = asyncio.create_task(heartbeat())
logger.info("Бот запущен")
await dp.start_polling(bot)
except Exception as e:
logger.error(f"Ошибка при запуске бота: {e}")
polling_task = asyncio.create_task(dp.start_polling(bot))
completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED)
for task in completed:
await task
finally:
from src.core.database import close_db
from contextlib import suppress
for task_name in ("polling_task", "health_task"):
task = locals().get(task_name)
if task is not None:
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
HEARTBEAT.unlink(missing_ok=True)
# Останавливаем планировщик
bot_scheduler.shutdown()
await bot.session.close()
await storage.close()
await close_db()
if __name__ == "__main__":
@@ -327,4 +374,5 @@ if __name__ == "__main__":
except KeyboardInterrupt:
logger.info("Бот остановлен пользователем")
except Exception as e:
logger.error(f"Критическая ошибка: {e}")
logger.exception("Критическая ошибка")
raise