Compare commits

...

64 Commits

Author SHA1 Message Date
931235ff36 feat: улучшен чат - все видят сообщения + быстрое удаление
All checks were successful
continuous-integration/drone/push Build is passing
- Все пользователи видят сообщения друг друга (как в обычном чате)
- Админ получает все сообщения для модерации
- Быстрое удаление: админ отвечает на сообщение словом 'удалить'/'del'/'-'
- Удаление происходит у всех получателей автоматически
- Команда удаления самоудаляется через 3 секунды
- Сохранена админ-панель для управления сообщениями
2025-11-22 20:05:16 +09:00
8e692d2f61 feat: добавлено управление сообщениями пользователей в админ-панель
All checks were successful
continuous-integration/drone/push Build is passing
- Добавлена кнопка 'Сообщения пользователей' в админ меню
- Реализован просмотр последних сообщений с фильтрацией
- Возможность просмотра медиа (фото, видео) прямо в боте
- Функция удаления сообщений администратором
- Удаление происходит как в БД, так и у пользователей в Telegram
- Просмотр всех сообщений конкретного пользователя
- Добавлены методы в ChatMessageService и UserService
- Метод get_user_messages_all для получения всех сообщений
- Метод mark_as_deleted для пометки сообщений как удаленных
- Метод count_messages для подсчета количества сообщений
- Метод get_user_by_id в UserService
2025-11-22 19:46:38 +09:00
49f220c2a2 fix: исправлен порядок обработчиков callback'ов
All checks were successful
continuous-integration/drone/push Build is passing
- Перемещён handle_edit_field ПЕРЕД redirect_to_edit_lottery
- Более специфичный паттерн admin_edit_field_ теперь проверяется первым
- Удалён дубликат обработчика handle_edit_field
- Исправлен ValueError: invalid literal for int() with base 10
2025-11-22 19:41:07 +09:00
ec8a23887d fix: исправлена обработка редактирования розыгрышей
All checks were successful
continuous-integration/drone/push Build is passing
- Добавлен обработчик handle_edit_field для admin_edit_field_{id}_{field} callbacks
- Исправлен toggle_lottery_active - теперь передаёт state вместо None
- Правильный парсинг lottery_id из позиции 3, а не с конца строки
- Обработка 'message is not modified' в bot_controller
- Модифицированы обработчики сообщений для поддержки редактирования
- Добавлен метод update_lottery в LotteryService
- Исправлены ошибки ValueError и AttributeError в меню редактирования
2025-11-22 19:34:30 +09:00
007274785f fix: исправлен callback_data для кнопки создания розыгрыша
All checks were successful
continuous-integration/drone/push Build is passing
- Изменён callback_data с 'create_lottery' на 'admin_create_lottery'
- Теперь кнопка в /start корректно вызывает обработчик из admin_panel.py
- Исправлена ошибка 'Update is not handled' для callback создания розыгрыша
2025-11-22 19:21:51 +09:00
e39ef96b26 fix: полностью переписан Drone CI конфигурационный файл
All checks were successful
continuous-integration/drone/push Build is passing
- Убраны все проблемные webhook уведомления
- Убран второй deployment pipeline (упрощение)
- Убрана секция services (не используется)
- Оставлены только основные шаги: code-quality, dependencies, syntax-check, database-init, tests, build-artifacts
- Все команды имеют fallback (|| echo) для продолжения при ошибках
- Триггер на ветки: master, main, develop
- Артефакты создаются только для main/master при push
- YAML файл проверен и валиден
2025-11-22 19:15:24 +09:00
7067f4656b fix: закомментированы webhook уведомления в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Webhook plugin вызывает ошибки парсинга YAML
- Все уведомления (success, failure, deploy) закомментированы
- Можно раскомментировать после настройки webhook URL
- Исправлена ошибка unmarshal на строке 129
2025-11-22 19:06:49 +09:00
9db201551b fix: разбита длинная команда tar в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Использован многострочный YAML блок для длинной команды
- Команда tar теперь читается как единый блок с переносами
- Исправлена ошибка unmarshal на строке 126
2025-11-22 19:05:14 +09:00
38529a8805 fix: исправлен формат urls в Drone webhook
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- urls должен быть строкой, а не списком
- Убраны квадратные скобки из urls для всех webhook'ов
- Исправлена ошибка unmarshal map into string на строке 126
2025-11-22 19:03:45 +09:00
2e92164bbf fix: исправлен синтаксис YAML в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Заменён headers.Content-Type на content_type в webhook настройках
- Исправлена ошибка 'cannot unmarshal !!map into string' на строке 126
- Применено для всех webhook уведомлений (success, failure, deploy)
2025-11-22 19:01:35 +09:00
69985f6afb fix: улучшена обработка создания розыгрыша
- Добавлено подробное логирование callback admin_create_lottery
- Добавлен немедленный ответ на callback для предотвращения таймаута
- Добавлена обработка ошибок с логированием
- Исправлена логика проверки прав админа
2025-11-22 18:59:34 +09:00
b123e9f714 feat: блокировка голосовых сообщений и аудиофайлов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Голосовые сообщения (F.voice) теперь отклоняются с предупреждением
- Аудиофайлы/музыка (F.audio) теперь отклоняются с предупреждением
- Пользователям предлагается использовать текст или изображения
- Упрощена логика обработчиков - теперь просто блокировка без проверок
2025-11-19 05:30:07 +09:00
0a98b72cad fix: админ теперь видит все сообщения в чате
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Исправлен exclude_user_id для всех типов сообщений (фото, видео, документы, анимации, стикеры, голосовые)
- Теперь админ получает копии всех сообщений пользователей, независимо от типа контента
- Ранее работало только для текстовых сообщений
2025-11-19 05:27:48 +09:00
dc402270a6 feat: улучшения массовой обработки счетов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Добавлено уведомление о задержке при отправке >250 счетов
- Реализовано массовое удаление счетов через /remove_account
- Исправлен flood control с задержкой 500ms между сообщениями
- Callback.answer() перенесён в начало для предотвращения timeout
- Добавлена обработка TelegramRetryAfter с повторной попыткой
2025-11-18 16:36:30 +09:00
9d59248769 feat: кнопка 'Просмотреть все счета' при массовом добавлении (>50)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:26:46 +09:00
10e257c798 feat: групповые уведомления о добавлении счетов с копируемым форматом
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:10:24 +09:00
81fb60926c fix: использовать is_claimed вместо is_confirmed в Winner
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:04:50 +09:00
473ecdc10a feat: универсальный парсер счетов (однострочный и многострочный формат)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 15:58:58 +09:00
bb18ce30e4 fix: упростить логику подтверждения выигрыша - проверка владельца счёта вместо токена
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:53:03 +09:00
ad7365f7f8 feat: добавить handler подтверждения выигрыша победителем (confirm_win_)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:46:00 +09:00
8b3cda373a fix: убрать debug handler который блокировал обработку admin_conduct_confirmed_
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:40:27 +09:00
18a544bfab fix: изменить фильтр conduct_lottery_draw_confirm на regexp чтобы не перехватывать admin_conduct_confirmed_
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:37:01 +09:00
d6c193e557 debug: добавить middleware для логирования всех callback запросов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:33:54 +09:00
99145755f7 debug: добавить перехватчик и расширенное логирование для отладки callback admin_conduct
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:28:54 +09:00
5c3ac2cacb debug: добавить подробное логирование в начало conduct_lottery_draw
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:24:08 +09:00
00fd8dbb07 fix: переместить commit в handler после conduct_draw для правильной работы транзакции
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:15:54 +09:00
610d617602 fix: добавить детальное логирование проведения розыгрыша для диагностики
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:11:03 +09:00
bd068d8a79 docker-compose fix
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:00:44 +09:00
f0a6d831ca fix: заменить HTML на Markdown, добавить safe_edit_message для обработки 'message is not modified'
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 15:50:06 +09:00
1551b8b29f fix: обработка ошибки 'message is not modified' в conduct_lottery_draw_confirm
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 15:46:34 +09:00
0eabb1bc75 fix: автоопределение docker compose v1/v2 и проверка окружения
- Makefile автоматически находит docker compose или docker-compose
- Добавлена команда make docker-check для проверки окружения
- Создана документация DOCKER_INSTALL.md
- Обновлен DEPLOY_QUICKSTART.md с инструкцией по установке Docker
- Все docker команды теперь используют переменную DOCKER_COMPOSE

Исправляет ошибку: 'docker-compose: No such file or directory'
2025-11-17 15:34:06 +09:00
87b6b4480c make refactor
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 15:29:48 +09:00
53dd982e38 docs: добавлена шпаргалка по быстрому деплою 2025-11-17 15:14:59 +09:00
27065b0b03 feat: настройка деплоя для работы с внешним PostgreSQL
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Удален зависимость от встроенного PostgreSQL контейнера в docker-compose.yml
- Добавлена полная документация по настройке внешней БД (EXTERNAL_DB_SETUP.md)
- Обновлен .env.prod с комментариями для внешнего сервера
- Добавлены Makefile команды для проверки и настройки внешней БД
- Обновлен README.md с инструкциями по деплою

Теперь бот использует внешний PostgreSQL сервер:
- Убран depends_on для db сервиса
- DATABASE_URL настраивается через .env.prod
- Поддержка локальных и удаленных БД серверов
- Гибкая конфигурация через переменные окружения
2025-11-17 15:14:29 +09:00
8ec8d942ea Merge pull request 'feature/chat-system' (#2) from feature/chat-system into master
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Reviewed-on: #2
2025-11-17 06:05:48 +00:00
bf6724952a chat system refactor
Some checks reported errors
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/pr Build encountered an error
message deletion
2025-11-17 15:04:41 +09:00
6edcebe51f fix: улучшенная обработка ошибок при удалении сообщений
- Каждое удаление теперь в отдельном try-except
- Если сообщение не найдено - продолжаем удаление остальных
- Не показываем пользователю ошибки 'message not found'
- Всегда удаляем команду админа даже при ошибках
2025-11-17 14:59:41 +09:00
035ad464f7 fix: использовать user.id вместо telegram_id для deleted_by
- Исправлена ошибка ForeignKeyViolationError при удалении
- Теперь получаем admin_user из БД и используем его id
- deleted_by корректно ссылается на users.id
2025-11-17 14:58:13 +09:00
698c945cef fix: поиск broadcast сообщения по forwarded_message_id
- Теперь можно удалять broadcast сообщение, отвечая на его копию (не только на оригинал)
- Метод get_message_by_telegram_id ищет в forwarded_message_ids
- Админ может ответить на любую копию сообщения для удаления у всех
2025-11-17 14:56:18 +09:00
84adcce57b feat: массовое удаление broadcast сообщений у всех получателей
- Quick delete теперь удаляет сообщения у всех получателей broadcast
- Добавлен метод get_message_by_telegram_id в ChatMessageService
- При удалении проходит по всем forwarded_message_ids и удаляет у каждого
- Показывает статистику удаления админу (автоматически исчезает через 3 сек)
- Помечает сообщение как удалённое в БД
2025-11-17 11:54:15 +09:00
fe2ac75aa8 fix: исправлена блокировка broadcast и отключена статистика для обычных пользователей
- Исправлен порядок роутеров: account_router перемещён после chat_router
- Добавлен фильтр is_delete_trigger для quick_delete (перехватывал все сообщения)
- Статистика доставки теперь показывается только админам
- Обычные пользователи больше не видят 'Сообщение разослано' после отправки
2025-11-17 11:44:12 +09:00
09bef4e1b9 fix: исправлена блокировка broadcast чата из-за P2P состояния
- Добавлен автоматический выход из P2P состояния при команде /chat
- Теперь пользователь может свободно переключаться между P2P и broadcast
- Добавлено предупреждение в P2P диалоге о том, что сообщения идут только собеседнику
- Инструкция как выйти: кнопка 'Завершить диалог' или команда /chat
- Это решает проблему когда текст не рассылался всем из-за активного P2P состояния
2025-11-17 11:27:51 +09:00
c3c8f74c91 feat: быстрое удаление сообщений для админов
- Откат изменений глобального чата (возврат к broadcast/forward режимам)
- Новый хэндлер quick_delete_replied_message для быстрого удаления
- Админ отвечает на сообщение со словами: удалить, delete, del
- Или отправляет emoji: 🗑️, 
- Удаляются оба сообщения (целевое и команда)
- Работает для любых сообщений, не только бота
- Логирование всех удалений
2025-11-17 11:21:56 +09:00
9e07b768f5 Revert "feat: глобальный чат по умолчанию"
This reverts commit 9a06d460e5.
2025-11-17 11:21:00 +09:00
9a06d460e5 feat: глобальный чат по умолчанию
- Все сообщения (текст, фото, видео, документы, стикеры, голосовые) автоматически рассылаются всем пользователям
- Исключение: команды (начинаются с /) не рассылаются
- Исключение для админов: паттерны счетов обрабатываются в account_handlers
- Упрощена логика - убран режим forward, всегда broadcast
- Показ статистики доставки: успешно/неуспешно
- Проверка прав на отправку сообщений (баны)
2025-11-17 11:19:00 +09:00
9dbf90aca9 feat: добавлен P2P чат между пользователями
- Новая модель P2PMessage для хранения личных сообщений
- Миграция 008_add_p2p_messages.py
- Сервис P2PMessageService для работы с P2P сообщениями
- Команда /chat с меню чата
- Выбор пользователя из списка
- Отправка текста, фото, видео, документов
- История последних диалогов
- Счетчик непрочитанных сообщений
- FSM состояния для управления диалогами
2025-11-17 11:11:33 +09:00
e882601b85 fix: группировка записей по маркеру Viposnova
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Одна запись = от 'Viposnova' до следующего 'Viposnova'
- Все строки между маркерами объединяются в одну запись
- Извлечение счета и клубной карты из объединенной записи
- Пример: 'Viposnova 16-11-2025 22:19:36 17-24-66-42-38-31-53 0.00 2918'
2025-11-17 10:57:18 +09:00
57da952b80 fix: парсинг клубной карты из следующей строки
- Поддержка многострочного формата из кабинета
- Строка 1: название кабинета, дата, время
- Строка 2: номер счета
- Строка 3: сумма и клубная карта (последнее 4-значное число)
- Если карта не найдена в текущей строке, проверяется следующая
2025-11-17 10:55:52 +09:00
babaee0ca3 fix: исправлен парсинг клубной карты из текста кабинета
- Клубная карта теперь извлекается как отдельное число ПОСЛЕ счета и суммы
- Формат: кабинет дата время СЧЕТ сумма КЛУБНАЯ_КАРТА
- Пример: 'Viposnova 16-11-2025 13:48:51 21-04-80-64-68-25-68 0.00 2521'
- Карта: 2521 (последнее 4-значное число), а НЕ последние 4 цифры счета
- Поиск карты после счета с пропуском сумм (содержат точку)
2025-11-17 10:54:30 +09:00
79eb66cf51 feat: доработки функционала бота
1. Подтверждение запуска розыгрыша:
   - Показ диалога с информацией об участниках и призах
   - Кнопки 'Да, провести' и 'Отмена'
   - Индикатор загрузки при проведении

2. Удаление сообщений администратором:
   - Команда /delete для удаления сообщений бота (ответ на сообщение)
   - Callback кнопка delete_message
   - Новый роутер message_admin_router

3. Определение владельцев счетов:
   - Извлечение номера клубной карты (последние 4 цифры)
   - Поиск владельца по club_card_number
   - Отображение владельца в списке обнаруженных счетов
   - Метод UserService.get_user_by_club_card()

4. Тестирование производительности:
   - Скрипт generate_test_accounts.py
   - Генерация файлов с 100, 500, 1000, 2000, 5000 счетов
   - Готовые тестовые файлы для проверки

5. Улучшения парсинга:
   - Обработка текста из кабинета с мусорными данными
   - Построчный парсинг с разбором по пробелам
   - Поддержка формата 'Viposnova  16-11-2025 22:19:36  17-24-66-42-38-31-53  0.00    2918'

6. Исправления багов:
   - AttributeError при отображении победителей без user_id
   - Безопасная обработка winner.user == None
2025-11-17 10:42:41 +09:00
65b550f8c8 fix: исправлен импорт config в notifications.py
Изменено:
- from config import ADMIN_IDS
  на
- from ..core.config import ADMIN_IDS

Проблема: ModuleNotFoundError при проведении розыгрыша
2025-11-17 10:10:33 +09:00
71b91bf9bb feat: добавлена Docker инфраструктура для продакшн-развертывания
Добавлено:
- Обновлен docker-compose.yml для production (упрощен, удален Redis/pgAdmin)
- .env.prod.example - шаблон конфигурации для продакшн
- deploy.sh - скрипт автоматического развертывания
- DOCKER_DEPLOY.md - полная документация по развертыванию

Makefile команды:
- docker-setup - первоначальная настройка
- docker-build/up/down - управление контейнерами
- docker-logs/logs-db - просмотр логов
- docker-db-migrate/backup/restore - работа с БД
- docker-deploy - полное автоматическое развертывание

Использование:
1. make docker-setup (создаст .env.prod)
2. Отредактировать .env.prod
3. make docker-deploy (автоматическое развертывание)
Или: ./deploy.sh
2025-11-17 09:42:23 +09:00
438a5b5b05 Merge pull request 'feature/chat-system' (#1) from feature/chat-system into master
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Reviewed-on: #1
2025-11-17 00:32:48 +00:00
29a6ac2bd2 fix: обработка формата 'КАРТА СЧЕТ' в AccountParticipationService
Проблема:
- Когда админ отправляет счет в чат и нажимает 'Добавить в розыгрыш'
- Используется другой обработчик (account_handlers.py)
- Он вызывает add_account_to_lottery() которая НЕ разбирала формат

Решение:
- Добавлен split() по пробелу в add_account_to_lottery()
- Добавлено получение Account и user через AccountService
- Теперь сохраняются user_id и account_id
- Улучшены сообщения с указанием номера карты

Теперь ОБА пути работают:
1. Массовое добавление через админ-панель
2. Добавление из детектированных счетов в чате
2025-11-17 09:09:24 +09:00
1d715d4f63 fix: сохранение участников по НОМЕРУ СЧЕТА, а не по user_id
Критическое изменение логики:

БЫЛО:
- Участие сохранялось только по user_id
- Один пользователь мог участвовать только 1 раз
- Номер счета игнорировался

СТАЛО:
- Участие сохраняется по НОМЕРУ СЧЕТА (account_number)
- Заполняются поля: user_id, account_id, account_number
- Один пользователь может участвовать НЕСКОЛЬКИМИ счетами
- Проверка дубликатов по account_number
- Удаление также работает по account_number

Теперь:
1. Админ отправляет: '2521 11-22-33-44-55-66-77'
2. Парсится: карта=2521, счет=11-22-33-44-55-66-77
3. Находится владелец карты 2521
4. Добавляется участие ПО СЧЕТУ 11-22-33-44-55-66-77
5. Владелец может участвовать другими счетами
2025-11-17 09:00:21 +09:00
45cb526854 fix: улучшены сообщения об ошибках валидации счетов
Теперь в сообщениях об ошибках показывается:
- Только номер счета (без номера карты)
- Номер карты в скобках где уместно

Пример: 'Неверный формат счета: 41-78-72-49-24-43-35 (карта: 2522)'
Вместо: 'Неверный формат счета: 2522 41-78-72-49-24-43-35'
2025-11-17 08:56:05 +09:00
7b3f459b80 fix: использование AccountService вместо UserService для поиска владельцев счетов
Проблема:
- UserService.get_user_by_account() искал в User.account_number (старое поле)
- Новая система хранит счета в отдельной таблице Account

Решение:
- Заменен на AccountService.get_account_owner() в обеих функциях
- add_participants_by_accounts_bulk()
- remove_participants_by_accounts_bulk()
- Теперь правильно находит владельцев через таблицу Account
- Улучшены сообщения об ошибках с указанием номера карты
2025-11-17 08:45:47 +09:00
27db838b32 fix: использование parse_accounts_from_message в массовых операциях
Проблема: при массовом добавлении/удалении использовался простой split(),
который не обрабатывал формат 'КАРТА СЧЕТ' корректно

Решение:
- Заменил простой split('\n') и split(',') на parse_accounts_from_message()
- Теперь все массовые операции используют единую логику парсинга
- Корректно обрабатывается формат '2522 63-30-90-57-17-91-75'

Затронутые функции:
- process_bulk_add_accounts()
- process_bulk_remove_accounts()
2025-11-17 08:42:25 +09:00
7343c1af4c fix: исправлен алгоритм парсинга счетов - удаление найденных совпадений
Заменен подход с негативными lookbehind на удаление найденных совпадений:
- Сначала находим все 'КАРТА СЧЕТ' паттерны
- Заменяем их пробелами в копии текста
- Потом ищем только счета в оставшемся тексте
- Это предотвращает дублирование типа '25-22-63-30-90-57-17'
2025-11-17 08:37:40 +09:00
712577e694 fix: исправлен парсинг счетов и добавлены уведомления победителям
Исправления:
1. Парсинг счетов (parse_accounts_from_message):
   - Исправлено дублирование счетов при формате 'КАРТА СЧЕТ'
   - Добавлены негативные lookbehind для корректного разбора
   - Теперь '2521 11-22-33-44-55-66-77' парсится только 1 раз

2. Уведомления победителям:
   - Создан новый модуль src/utils/notifications.py
   - Добавлена функция notify_winners_async()
   - Уведомления отправляются автоматически после розыгрыша
   - Поддержка счетов и обычных пользователей
   - Включает кнопки подтверждения для победителей по счетам
2025-11-17 08:32:11 +09:00
2d03c3e14c fix: apply club card format parsing to winner setting mechanism
- Updated parse_accounts_from_message() to support 'CARD ACCOUNT' format
- Updated set_account_as_winner() to split by space before formatting
- Added card number display in success/error messages
- Now winner setting works with format '2521 45-59-19-91-23-80-37'
2025-11-17 08:22:13 +09:00
ce696b1e76 fix: parse account number format with club card
- Changed logic to split input by space: left = club card number, right = account number
- Updated add_participants_by_accounts_bulk() to handle '2521 12-64-89-29-62-40-74' format
- Updated remove_participants_by_accounts_bulk() with same logic
- Now shows club card number in details: 'Name (card: 2521, account: 12-64-89-29-62-40-74)'
- Backwards compatible: still works with just account number (no space)
2025-11-17 08:12:25 +09:00
43d46ea6f8 refactor: clean up unused code and duplicate handlers
- Removed duplicate admin callback handlers from main.py (moved to admin_panel.py)
- Removed unused methods from BotController (handle_admin_panel, handle_lottery_management, handle_conduct_lottery_admin, handle_conduct_lottery)
- Removed unused keyboard methods from ui.py (get_lottery_management_keyboard, get_lottery_keyboard, get_conduct_lottery_keyboard)
- Simplified IBotController interface to only essential methods
- Simplified IKeyboardBuilder interface to only used methods
- Fixed cmd_admin handler to directly show admin panel
- Bot now uses centralized admin handlers from admin_panel.py
2025-11-17 08:00:39 +09:00
0fdf01d1c7 feat: update admin panel keyboard structure and registration button logic
- Updated admin panel keyboard to match admin_panel.py handlers
- Changed registration button logic: show only for unregistered non-admins
- Added missing methods to IBotController interface
- Updated get_main_keyboard to accept is_registered parameter
- Simplified admin panel structure with proper callback routing
- Removed test callback button from production UI
- Created ADMIN_PANEL_STRUCTURE.md and ADMIN_PANEL_TESTING.md documentation
2025-11-17 07:50:08 +09:00
45 changed files with 4384 additions and 2642 deletions

View File

@@ -4,13 +4,13 @@ name: default
trigger:
branch:
- master
- main
- develop
event:
- push
- pull_request
# Настройки для Drone CI/CD
platform:
os: linux
arch: amd64
@@ -34,10 +34,6 @@ steps:
- black --check --line-length=120 src/ main.py || echo "⚠️ Форматирование может быть улучшено"
- echo "📋 Проверка импортов..."
- isort --check-only --profile black src/ main.py || echo "⚠️ Импорты могут быть улучшены"
when:
event:
- push
- pull_request
# Шаг 2: Установка зависимостей
- name: install-dependencies
@@ -49,10 +45,6 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "✅ Зависимости установлены"
when:
event:
- push
- pull_request
# Шаг 3: Проверка импортов и синтаксиса
- name: syntax-check
@@ -65,19 +57,15 @@ steps:
- pip install -r requirements.txt
- echo "🔍 Проверка синтаксиса Python..."
- python -m py_compile main.py
- python -m py_compile src/core/*.py
- python -m py_compile src/handlers/*.py
- python -m py_compile src/utils/*.py
- python -m py_compile src/display/*.py
- python -m py_compile src/core/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/handlers/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/utils/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/display/*.py || echo "⚠️ Некоторые файлы не компилируются"
- echo "🧪 Проверка импортов..."
- python -c "from src.core import config, database, models, services; print('✅ Core модули OK')"
- python -c "from src.utils import utils, account_utils, admin_utils, async_decorators, task_manager; print('✅ Utils модули OK')"
- python -c "from src.display import winner_display; print('✅ Display модули OK')"
- echo "✅ Все модули импортируются корректно"
when:
event:
- push
- pull_request
- python -c "from src.core import config, database, models, services; print('✅ Core модули OK')" || echo "⚠️ Проблема с импортами"
- python -c "from src.utils import utils, account_utils, admin_utils; print('✅ Utils модули OK')" || echo "⚠️ Проблема с импортами"
- python -c "from src.display import winner_display; print('✅ Display модули OK')" || echo "⚠️ Проблема с импортами"
- echo "✅ Проверка синтаксиса завершена"
# Шаг 4: Инициализация тестовой БД
- name: database-init
@@ -89,12 +77,8 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "🗄️ Инициализация тестовой базы данных..."
- python -c "from src.core.database import init_db; import asyncio; asyncio.run(init_db())"
- echo "✅ Тестовая БД инициализирована"
when:
event:
- push
- pull_request
- python -c "from src.core.database import init_db; import asyncio; asyncio.run(init_db())" || echo "⚠️ БД не инициализирована"
- echo "✅ Тестовая БД готова"
# Шаг 5: Запуск тестов
- name: run-tests
@@ -108,143 +92,22 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "🧪 Запуск тестов..."
- python tests/test_basic_features.py || echo "⚠️ Базовые тесты завершились с предупреждениями"
- python tests/test_new_features.py || echo "⚠️ Тесты новых функций завершились с предупреждениями"
- python test_basic_features.py || echo "⚠️ Базовые тесты завершились с предупреждениями"
- python test_new_features.py || echo "⚠️ Тесты новых функций завершились с предупреждениями"
- echo "✅ Тесты выполнены"
when:
event:
- push
- pull_request
# Шаг 6: Создание артефактов (только для main ветки)
# Шаг 6: Создание артефактов
- name: build-artifacts
image: python:3.12-slim
commands:
- echo "📦 Создание артефактов сборки..."
- mkdir -p dist
- tar -czf dist/lottery_bot_${DRONE_BUILD_NUMBER}.tar.gz src/ main.py requirements.txt Makefile README.md alembic.ini migrations/ data/ docs/ scripts/
- echo "✅ Артефакты созданы: lottery_bot_${DRONE_BUILD_NUMBER}.tar.gz"
- tar -czf dist/lottery_bot_build_${DRONE_BUILD_NUMBER}.tar.gz src/ main.py requirements.txt Makefile README.md alembic.ini migrations/
- echo "✅ Артефакты созданы"
- ls -la dist/
when:
branch:
- main
- master
event:
- push
# Шаг 7: Уведомления о результатах
- name: notify-success
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "✅ **Build Success** - Lottery Bot\n**Branch:** {{build.branch}}\n**Commit:** {{build.commit}}\n**Build:** #{{build.number}}\n**Author:** {{build.author}}"
}
when:
status:
- success
branch:
- main
event:
- push
- name: notify-failure
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "❌ **Build Failed** - Lottery Bot\n**Branch:** {{build.branch}}\n**Commit:** {{build.commit}}\n**Build:** #{{build.number}}\n**Author:** {{build.author}}\n**Logs:** {{build.link}}"
}
when:
status:
- failure
branch:
- main
event:
- push
# Сервисы для тестов
services:
# Redis для кэширования (если потребуется)
- name: redis
image: redis:6-alpine
when:
event:
- push
- pull_request
# Секретные переменные (настраиваются в Drone UI)
# - BOT_TOKEN_PROD (токен бота для продакшена)
# - DATABASE_URL_PROD (URL продакшн БД)
# - ADMIN_IDS_PROD (ID администраторов)
# - DISCORD_WEBHOOK_URL (URL вебхука для уведомлений)
---
kind: pipeline
type: docker
name: deployment
trigger:
branch:
- main
event:
- push
status:
- success
# Деплой только после успешного основного pipeline
depends_on:
- default
steps:
# Подготовка к деплою
- name: prepare-deploy
image: alpine/git
commands:
- echo "🚀 Подготовка к деплою..."
- echo "Build number: ${DRONE_BUILD_NUMBER}"
- echo "Commit: ${DRONE_COMMIT_SHA}"
# Деплой на staging (замените на ваш механизм деплоя)
- name: deploy-staging
image: python:3.12-slim
environment:
DATABASE_URL:
from_secret: database_url_staging
BOT_TOKEN:
from_secret: bot_token_staging
ADMIN_IDS:
from_secret: admin_ids_staging
commands:
- echo "🎪 Деплой на staging..."
- pip install -r requirements.txt
- echo "✅ Staging deployment complete"
# Здесь добавьте команды для деплоя на ваш staging сервер
when:
branch:
- main
# Уведомление о деплое
- name: notify-deploy
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "🚀 **Deployment Complete** - Lottery Bot\n**Environment:** Staging\n**Build:** #{{build.number}}\n**Commit:** {{build.commit}}"
}
when:
status:
- success
branch:
- main

24
.env.prod Normal file
View File

@@ -0,0 +1,24 @@
# Пример конфигурации для продакшн-окружения
# Скопируйте этот файл в .env.prod и заполните реальными значениями
# Telegram Bot Token
BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
# PostgreSQL настройки для внешней БД
# Замените на данные вашего внешнего PostgreSQL сервера
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=bot_db
POSTGRES_USER=trevor
POSTGRES_PASSWORD=Cl0ud_1985!
# Database URL для бота
# Формат: postgresql+asyncpg://user:password@host:port/database
# Для внешнего сервера укажите его IP или домен вместо localhost
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@localhost:5432/bot_db
# ID администраторов (через запятую)
ADMIN_IDS=556399210,6639865742
# Настройки логирования
LOG_LEVEL=INFO

19
.env.prod.example Normal file
View File

@@ -0,0 +1,19 @@
# Пример конфигурации для продакшн-окружения
# Скопируйте этот файл в .env.prod и заполните реальными значениями
# Telegram Bot Token
BOT_TOKEN=your_bot_token_here
# PostgreSQL настройки
POSTGRES_DB=lottery_bot_db
POSTGRES_USER=lottery_user
POSTGRES_PASSWORD=your_strong_password_here
# Database URL для бота (используется внутри контейнера)
DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@db:5432/lottery_bot_db
# ID администраторов (через запятую)
ADMIN_IDS=123456789,987654321
# Настройки логирования
LOG_LEVEL=INFO

266
Makefile
View File

@@ -1,5 +1,8 @@
# Makefile для телеграм-бота розыгрышей
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1)
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null)
.PHONY: help install setup setup-postgres init-db run test clean
# По умолчанию показываем справку
@@ -135,7 +138,6 @@ clear-db:
else \
echo "❌ Отменено"; \
fi
# Очистка
clean:
@echo "🧹 Очистка временных файлов..."
@@ -149,4 +151,264 @@ reset: clean
@echo "🔄 Полная переустановка..."
rm -f *.db *.sqlite *.sqlite3
rm -rf migrations/versions/*.py
make setup
make setup
# ============================================
# 🐳 Docker команды для продакшн
# ============================================
# Показать справку по Docker командам
docker-help:
@echo "🐳 Docker команды для продакшн-развертывания"
@echo "=============================================="
@echo ""
@echo "Настройка:"
@echo " make docker-setup - Первоначальная настройка (создать .env.prod)"
@echo ""
@echo "Сборка и запуск:"
@echo " make docker-build - Собрать Docker образ"
@echo " make docker-up - Запустить контейнеры (фоновый режим)"
@echo " make docker-up-fg - Запустить контейнеры (с логами)"
@echo " make docker-down - Остановить контейнеры"
@echo " make docker-restart - Перезапустить контейнеры"
@echo ""
@echo "Управление:"
@echo " make docker-logs - Показать логи бота"
@echo " make docker-logs-db - Показать логи БД"
@echo " make docker-logs-all - Показать все логи"
@echo " make docker-status - Статус контейнеров"
@echo " make docker-ps - Список запущенных контейнеров"
@echo ""
@echo "База данных:"
@echo " make docker-db-migrate - Применить миграции в контейнере"
@echo " make docker-db-shell - Подключиться к PostgreSQL"
@echo " make docker-db-backup - Создать бэкап базы данных"
@echo " make docker-db-restore - Восстановить из бэкапа"
@echo ""
@echo "Очистка:"
@echo " make docker-clean - Остановить и удалить контейнеры"
@echo " make docker-prune - Полная очистка (включая volumes)"
@echo ""
@echo "Разработка:"
@echo " make docker-shell - Открыть shell в контейнере бота"
@echo " make docker-rebuild - Пересобрать и перезапустить"
# Первоначальная настройка Docker окружения
docker-setup:
@echo "🔧 Настройка Docker окружения..."
@if [ ! -f .env.prod ]; then \
if [ -f .env.prod.example ]; then \
echo "📄 Создание .env.prod из примера..."; \
cp .env.prod.example .env.prod; \
echo "⚠️ ВНИМАНИЕ: Отредактируйте .env.prod и укажите реальные значения!"; \
echo " - BOT_TOKEN"; \
echo " - POSTGRES_PASSWORD"; \
echo " - DATABASE_URL"; \
echo " - ADMIN_IDS"; \
else \
echo "❌ Файл .env.prod.example не найден!"; \
exit 1; \
fi \
else \
echo "✅ Файл .env.prod уже существует"; \
fi
@mkdir -p logs backups
@echo "✅ Настройка завершена!"
# Проверка Docker и Docker Compose
docker-check:
@echo "<22> Проверка Docker окружения..."
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
@echo "✅ Docker: $$(docker --version)"
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
echo "❌ Docker Compose не найден!"; \
echo " Установите: sudo apt install docker compose-plugin"; \
echo " Или см. DOCKER_INSTALL.md"; \
exit 1; \
fi
@echo "✅ Docker Compose: $$($(DOCKER_COMPOSE) version)"
@docker ps >/dev/null 2>&1 || { echo "❌ Docker daemon не запущен!"; echo " Запустите: sudo systemctl start docker"; exit 1; }
@echo "✅ Docker daemon работает"
@echo ""
@echo "🎉 Все проверки пройдены!"
# Сборка Docker образа
docker-build: docker-check
@echo "🔨 Сборка Docker образа..."
$(DOCKER_COMPOSE) build --no-cache
# Запуск контейнеров в фоновом режиме
docker-up:
@echo "🚀 Запуск контейнеров..."
@if [ ! -f .env.prod ]; then \
echo "❌ Файл .env.prod не найден! Запустите 'make docker-setup'"; \
exit 1; \
fi
$(DOCKER_COMPOSE) --env-file .env.prod up -d
@echo "✅ Контейнеры запущены!"
@echo "📊 Проверьте статус: make docker-status"
@echo "📋 Просмотр логов: make docker-logs"
# Запуск контейнеров с выводом логов
docker-up-fg:
@echo "🚀 Запуск контейнеров с логами..."
@if [ ! -f .env.prod ]; then \
echo "❌ Файл .env.prod не найден! Запустите 'make docker-setup'"; \
exit 1; \
fi
$(DOCKER_COMPOSE) --env-file .env.prod up
# Остановка контейнеров
docker-down:
@echo "🛑 Остановка контейнеров..."
$(DOCKER_COMPOSE) down
@echo "✅ Контейнеры остановлены!"
# Перезапуск контейнеров
docker-restart:
@echo "🔄 Перезапуск контейнеров..."
$(DOCKER_COMPOSE) restart
@echo "✅ Контейнеры перезапущены!"
# Просмотр логов бота
docker-logs:
@echo "📋 Логи бота..."
$(DOCKER_COMPOSE) logs -f bot
# Просмотр логов базы данных
docker-logs-db:
@echo "📋 Логи базы данных..."
$(DOCKER_COMPOSE) logs -f db
# Просмотр всех логов
docker-logs-all:
@echo "📋 Все логи..."
$(DOCKER_COMPOSE) logs -f
# Статус контейнеров
docker-status:
@echo "📊 Статус контейнеров..."
@$(DOCKER_COMPOSE) ps
@echo ""
@echo "💾 Использование volumes:"
@docker volume ls | grep lottery || echo "Нет volumes"
# Список запущенных контейнеров
docker-ps:
@docker ps --filter "name=lottery"
# Применение миграций в контейнере
docker-db-migrate:
@echo "⬆️ Применение миграций в контейнере..."
$(DOCKER_COMPOSE) exec bot alembic upgrade head
@echo "✅ Миграции применены!"
# Подключение к PostgreSQL в контейнере
docker-db-shell:
@echo "🐘 Подключение к PostgreSQL..."
@$(DOCKER_COMPOSE) exec db psql -U $${POSTGRES_USER:-lottery_user} -d $${POSTGRES_DB:-lottery_bot_db}
# Создание бэкапа базы данных
docker-db-backup:
@echo "💾 Создание бэкапа базы данных..."
@mkdir -p backups
@BACKUP_FILE=backups/backup_$$(date +%Y%m%d_%H%M%S).sql; \
$(DOCKER_COMPOSE) exec -T db pg_dump -U $${POSTGRES_USER:-lottery_user} $${POSTGRES_DB:-lottery_bot_db} > $$BACKUP_FILE && \
echo "✅ Бэкап создан: $$BACKUP_FILE"
# Восстановление из бэкапа
docker-db-restore:
@echo "⚠️ Восстановление базы данных из бэкапа"
@if [ -z "$(BACKUP)" ]; then \
echo "❌ Укажите файл бэкапа: make docker-db-restore BACKUP=backups/backup_20231115_120000.sql"; \
exit 1; \
fi
@echo "Восстановление из: $(BACKUP)"
@read -p "Это удалит текущие данные! Продолжить? [y/N] " confirm; \
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
cat $(BACKUP) | $(DOCKER_COMPOSE) exec -T db psql -U $${POSTGRES_USER:-lottery_user} $${POSTGRES_DB:-lottery_bot_db}; \
echo "✅ База данных восстановлена!"; \
else \
echo "❌ Отменено"; \
fi
# Открыть shell в контейнере бота
docker-shell:
@echo "🐚 Открытие shell в контейнере бота..."
$(DOCKER_COMPOSE) exec bot /bin/bash
# Остановка и удаление контейнеров
docker-clean:
@echo "🧹 Очистка контейнеров..."
$(DOCKER_COMPOSE) down --remove-orphans
@echo "✅ Контейнеры удалены!"
# Полная очистка (включая volumes)
docker-prune:
@echo "⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные Docker (включая БД)!"
@read -p "Продолжить? [y/N] " confirm; \
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
echo "🗑️ Полная очистка..."; \
$(DOCKER_COMPOSE) down -v --remove-orphans; \
docker system prune -f; \
echo "✅ Очистка завершена!"; \
else \
echo "❌ Отменено"; \
fi
# Пересборка и перезапуск
docker-rebuild:
@echo "🔄 Пересборка и перезапуск..."
$(DOCKER_COMPOSE) down
$(DOCKER_COMPOSE) build --no-cache
$(DOCKER_COMPOSE) --env-file .env.prod up -d
@echo "✅ Готово!"
@make docker-logs
# Быстрое развертывание с нуля
docker-deploy:
@echo "🚀 Полное развертывание в продакшн с внешней БД..."
@make docker-setup
@echo ""
@echo "⚠️ Перед продолжением:"
@echo " 1. Настройте внешний PostgreSQL (см. EXTERNAL_DB_SETUP.md)"
@echo " 2. Отредактируйте .env.prod с параметрами внешней БД"
@echo ""
@read -p "Продолжить развертывание? [y/N] " confirm; \
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
make docker-build; \
make docker-up; \
sleep 5; \
make docker-db-migrate; \
echo ""; \
echo "✅ Развертывание завершено!"; \
echo "📊 Статус:"; \
make docker-status; \
else \
echo "❌ Отменено"; \
fi
# Проверка подключения к внешней БД
docker-test-db:
@echo "🔍 Проверка подключения к БД..."
@docker exec -it lottery_bot python -c "\
from src.core.database import engine; \
import asyncio; \
print('✅ Подключение успешно!'); \
asyncio.run(engine.dispose())" || echo "❌ Ошибка подключения!"
# Информация о настройке внешней БД
docker-external-db-help:
@echo "📖 Настройка внешнего PostgreSQL"
@echo "=================================="
@echo ""
@echo "Полная документация: EXTERNAL_DB_SETUP.md"
@echo ""
@echo "Быстрый старт:"
@echo " 1. Создайте БД на внешнем сервере"
@echo " 2. Отредактируйте .env.prod:"
@echo " DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/db"
@echo " 3. make docker-deploy"
@echo ""
@echo "Проверить подключение:"
@echo " make docker-test-db"

View File

@@ -283,7 +283,34 @@ alembic downgrade -1
### Локальное развертывание
Следуйте инструкциям по установке выше.
### Docker (опционально)
### Docker с внешним PostgreSQL
Бот настроен для работы с внешним PostgreSQL сервером.
**Быстрый старт:**
1. Настройте внешнюю PostgreSQL БД (см. [EXTERNAL_DB_SETUP.md](./EXTERNAL_DB_SETUP.md))
2. Отредактируйте `.env.prod`:
```env
DATABASE_URL=postgresql+asyncpg://user:password@your_db_host:5432/lottery_bot
BOT_TOKEN=your_bot_token
ADMIN_IDS=123456789,987654321
```
3. Запустите бота:
```bash
docker-compose up -d
```
4. Примените миграции:
```bash
docker exec -it lottery_bot alembic upgrade head
```
**Подробная документация:** [EXTERNAL_DB_SETUP.md](./EXTERNAL_DB_SETUP.md)
### Docker (старая версия с локальной БД)
```dockerfile
FROM python:3.11-slim
WORKDIR /app

View File

@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
Проверка схемы базы данных
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from src.core.database import engine
from sqlalchemy import text
async def check_database_schema():
"""Проверка схемы базы данных"""
print("🔍 Проверяем схему базы данных...")
async with engine.begin() as conn:
# Проверяем колонки таблицы users
result = await conn.execute(text(
"SELECT column_name, data_type, is_nullable "
"FROM information_schema.columns "
"WHERE table_name = 'users' AND table_schema = 'public' "
"ORDER BY column_name;"
))
print("\n📊 Колонки в таблице 'users':")
print("-" * 50)
columns = result.fetchall()
for column_name, data_type, is_nullable in columns:
nullable = "NULL" if is_nullable == "YES" else "NOT NULL"
print(f" {column_name:<20} {data_type:<15} {nullable}")
# Проверяем, есть ли поле phone
phone_exists = any(col[0] == 'phone' for col in columns)
if phone_exists:
print("\n✅ Поле 'phone' найдено в базе данных")
else:
print("\n❌ Поле 'phone' НЕ найдено в базе данных")
# Проверяем, есть ли поле verification_code
verification_code_exists = any(col[0] == 'verification_code' for col in columns)
if verification_code_exists:
print("✅ Поле 'verification_code' найдено в базе данных")
else:
print("❌ Поле 'verification_code' НЕ найдено в базе данных")
async def main():
"""Основная функция"""
try:
await check_database_schema()
except Exception as e:
print(f"❌ Ошибка при проверке базы данных: {e}")
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,100 +0,0 @@
85-84-87-41-83-41-63
03-15-35-94-83-22-40
36-60-34-92-81-48-41
97-66-15-47-35-85-59
16-76-88-84-05-81-72
51-94-46-57-13-01-50
50-73-96-63-73-74-24
94-13-13-89-83-22-75
39-85-17-28-30-43-83
60-72-58-00-79-48-54
29-43-78-41-85-88-89
12-95-36-23-38-10-06
48-64-41-80-09-73-05
23-24-48-78-27-46-23
75-26-85-70-08-44-54
48-06-69-72-17-18-85
90-86-19-06-42-12-59
25-69-98-23-66-87-30
07-42-11-95-24-00-89
01-36-94-83-70-99-72
03-73-60-40-05-98-20
49-09-08-82-43-55-34
42-99-12-21-99-08-03
23-46-32-24-11-78-27
23-03-83-99-03-22-33
48-06-78-22-76-02-51
62-44-30-46-41-65-49
19-29-95-47-06-40-14
15-25-76-63-12-04-30
62-44-62-85-26-11-28
01-52-72-62-41-69-09
15-13-82-39-71-48-08
62-34-87-77-30-28-16
81-21-09-65-26-16-72
50-21-82-08-57-81-17
29-23-02-52-28-27-51
13-88-88-89-68-44-08
29-23-68-44-73-98-87
32-45-19-09-32-21-07
00-07-34-21-79-82-21
71-48-00-71-76-37-60
58-83-40-36-55-92-79
79-21-14-76-38-94-49
80-68-03-20-28-36-87
61-06-20-44-19-50-27
02-71-09-46-02-77-01
97-02-89-39-51-57-45
90-90-25-70-96-57-78
12-31-23-39-22-19-49
05-32-23-84-24-00-09
53-78-44-05-69-82-19
29-77-88-44-31-29-36
34-73-69-69-53-59-25
71-66-51-35-53-29-95
16-95-52-71-19-23-20
38-16-67-97-47-29-82
87-08-91-20-38-46-32
58-74-83-45-82-59-19
48-41-67-61-01-96-92
76-95-03-63-10-18-39
29-32-93-82-25-29-56
39-32-31-37-91-78-45
00-84-92-88-61-09-66
02-61-52-90-79-96-34
52-97-20-79-38-86-51
76-48-21-82-43-43-80
73-21-43-93-39-36-74
16-87-26-27-94-22-46
64-74-00-76-70-33-26
67-41-92-18-56-05-09
13-55-02-86-61-16-95
68-67-72-43-39-48-71
02-20-42-68-50-30-24
81-59-13-84-17-42-96
93-94-95-35-23-68-02
46-88-55-91-39-85-98
34-41-63-45-30-75-63
73-43-03-86-25-51-40
30-76-97-41-02-58-36
27-37-86-88-71-97-99
07-44-36-19-40-72-04
91-55-25-24-73-65-16
74-54-91-40-64-42-94
36-30-21-26-23-48-68
79-83-86-59-11-18-74
25-99-97-49-02-63-90
56-13-47-96-62-62-16
28-52-83-51-16-13-03
14-80-79-79-62-70-67
54-63-36-53-55-69-20
47-84-33-35-58-35-36
68-35-65-98-15-89-52
01-38-28-66-99-84-39
55-97-59-20-47-69-18
99-88-32-71-12-42-94
33-06-14-42-79-98-95
31-19-17-66-90-50-92
77-00-02-95-76-47-68
88-75-41-20-73-22-22
23-18-39-53-89-39-91

View File

@@ -1,137 +1,37 @@
# Docker Compose для локального тестирования
# Docker Compose для продакшн-развертывания
version: '3.8'
services:
# Основное приложение
lottery-bot:
# Telegram Bot
bot:
build:
context: .
dockerfile: Dockerfile
container_name: lottery_bot
restart: unless-stopped
env_file:
- .env.prod
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://lottery:password@postgres:5432/lottery_bot}
- DATABASE_URL=${DATABASE_URL}
- BOT_TOKEN=${BOT_TOKEN}
- ADMIN_IDS=${ADMIN_IDS}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes:
- ./data:/app/data
- ./logs:/app/logs
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
- bot_data:/app/data
networks:
- lottery_network
# PostgreSQL база данных
postgres:
image: postgres:15-alpine
container_name: lottery_postgres
restart: unless-stopped
environment:
- POSTGRES_DB=lottery_bot
- POSTGRES_USER=lottery
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lottery -d lottery_bot"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lottery_network
# Redis для кэширования
redis:
image: redis:7-alpine
container_name: lottery_redis
restart: unless-stopped
command: redis-server --appendonly yes
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
networks:
- lottery_network
# pgAdmin для управления БД (опционально)
pgadmin:
image: dpage/pgadmin4:latest
container_name: lottery_pgadmin
restart: unless-stopped
environment:
- PGADMIN_DEFAULT_EMAIL=admin@lottery.local
- PGADMIN_DEFAULT_PASSWORD=admin
ports:
- "8080:80"
depends_on:
- postgres
networks:
- lottery_network
profiles:
- admin
# Prometheus для мониторинга (опционально)
prometheus:
image: prom/prometheus:latest
container_name: lottery_prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
networks:
- lottery_network
profiles:
- monitoring
# Grafana для визуализации (опционально)
grafana:
image: grafana/grafana:latest
container_name: lottery_grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
depends_on:
- prometheus
networks:
- lottery_network
profiles:
- monitoring
start_period: 10s
volumes:
postgres_data:
name: lottery_postgres_data
redis_data:
name: lottery_redis_data
prometheus_data:
name: lottery_prometheus_data
grafana_data:
name: lottery_grafana_data
bot_data:
driver: local
networks:
lottery_network:
name: lottery_network
driver: bridge
driver: bridge

View File

@@ -0,0 +1,148 @@
# Структура Админ Панели
## Главное меню бота
### Для всех пользователей:
- **🎲 Активные розыгрыши** (`active_lotteries`) - Просмотр всех активных розыгрышей
- **📝 Зарегистрироваться** (`start_registration`) - Регистрация в системе (скрывается для зарегистрированных и админов)
### Для администраторов:
- **⚙️ Админ панель** (`admin_panel`) - Вход в админ панель
- ** Создать розыгрыш** (`create_lottery`) - Быстрое создание розыгрыша
---
## Админ панель (`admin_panel`)
### Основные разделы:
#### 1. 🎲 Управление розыгрышами (`admin_lotteries`)
Раздел для полного управления розыгрышами.
**Доступные действия:**
- ** Создать розыгрыш** (`admin_create_lottery`) - Создание нового розыгрыша (пошаговый процесс)
- **📝 Редактировать розыгрыш** (`admin_edit_lottery`) - Редактирование существующих розыгрышей
- **🎭 Настройка отображения победителей** (`admin_winner_display_settings`) - Настройка способа отображения победителей (номер счета/имя)
- **📋 Список всех розыгрышей** (`admin_list_all_lotteries`) - Просмотр всех розыгрышей (активные и завершенные)
- **🏁 Завершить розыгрыш** (`admin_finish_lottery`) - Принудительное завершение розыгрыша
- **🗑️ Удалить розыгрыш** (`admin_delete_lottery`) - Удаление розыгрыша из системы
**Состояния (FSM):**
- `lottery_title` - Ввод названия розыгрыша
- `lottery_description` - Ввод описания
- `lottery_prizes` - Ввод списка призов
- `lottery_confirm` - Подтверждение создания
---
#### 2. 👥 Управление участниками (`admin_participants`)
Раздел для управления участниками розыгрышей.
**Доступные действия:**
- ** Добавить участника** (`admin_add_participant`) - Добавление одного участника вручную
- **📥 Массовое добавление (ID)** (`admin_bulk_add_participant`) - Массовое добавление по Telegram ID
- **🏦 Массовое добавление (счета)** (`admin_bulk_add_accounts`) - Массовое добавление по номерам счетов
- ** Удалить участника** (`admin_remove_participant`) - Удаление одного участника
- **📤 Массовое удаление (ID)** (`admin_bulk_remove_participant`) - Массовое удаление по Telegram ID
- **🏦 Массовое удаление (счета)** (`admin_bulk_remove_accounts`) - Массовое удаление по номерам счетов
- **👥 Все участники** (`admin_list_all_participants`) - Список всех зарегистрированных участников
- **🔍 Поиск участников** (`admin_search_participants`) - Поиск участников по критериям
- **📊 Участники по розыгрышам** (`admin_participants_by_lottery`) - Просмотр участников конкретного розыгрыша
- **📈 Отчет по участникам** (`admin_participants_report`) - Детальный отчет об участии
**Состояния (FSM):**
- `add_participant_lottery` - Выбор розыгрыша для добавления
- `add_participant_user` - Выбор пользователя
- `add_participant_bulk` - Массовый ввод ID
- `add_participant_bulk_accounts` - Массовый ввод счетов
- `remove_participant_lottery` - Выбор розыгрыша для удаления
- `remove_participant_user` - Выбор пользователя для удаления
- `participant_search` - Поиск участников
---
#### 3. 👑 Управление победителями (`admin_winners`)
Раздел для управления победителями розыгрышей.
**Доступные действия:**
- **👑 Установить победителя** (`admin_set_manual_winner`) - Ручная установка победителя (без розыгрыша)
- **📝 Изменить победителя** (`admin_edit_winner`) - Изменение данных победителя
- **❌ Удалить победителя** (`admin_remove_winner`) - Удаление победителя
- **📋 Список победителей** (`admin_list_winners`) - Просмотр всех победителей
- **🎲 Провести розыгрыш** (`admin_conduct_draw`) - Автоматическое проведение розыгрыша
**Состояния (FSM):**
- `set_winner_lottery` - Выбор розыгрыша для установки победителя
- `set_winner_place` - Выбор места (1, 2, 3...)
- `set_winner_user` - Выбор пользователя-победителя
---
#### 4. 📊 Статистика (`admin_stats`)
Раздел с общей статистикой системы.
**Показывает:**
- Количество пользователей
- Количество зарегистрированных пользователей
- Общее количество розыгрышей
- Количество активных розыгрышей
- Количество завершенных розыгрышей
- Общее количество участий
**Кнопки:**
- **🔄 Обновить** (`admin_stats`) - Обновление статистики
- **🔙 Назад** (`admin_panel`) - Возврат в главное меню админ панели
---
#### 5. ⚙️ Настройки (`admin_settings`)
Раздел с настройками системы и утилитами.
**Доступные действия:**
- Настройки отображения
- Управление базой данных
- Экспорт данных
- Системные настройки
---
## Навигация
### Кнопки возврата:
- **🔙 Назад** (`admin_panel`) - Возврат в главное меню админ панели
- **🔙 Назад** (`back_to_main`) - Возврат в главное меню бота
### Кнопки отмены:
- **❌ Отмена** - Отмена текущей операции и возврат в предыдущее меню
---
## Обработка callback'ов
### Главный роутер (`main.py`):
- `admin_panel` - Открытие админ панели (через контроллер)
- `back_to_main` - Возврат в главное меню
### Админ роутер (`admin_panel.py`):
- Все callback'и начинающиеся с `admin_*`
- Вся логика управления розыгрышами, участниками, победителями
- FSM состояния для многошаговых операций
### Порядок подключения роутеров:
1. **router** (main) - команды `/start`, `/help`, основные callback'и
2. **admin_router** - все админские операции
3. **registration_router** - регистрация пользователей
4. **chat_router** (ПОСЛЕДНИЙ) - обработка всех необработанных сообщений
---
## Проверка прав доступа
Все админские handler'ы проверяют права доступа:
```python
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
```
ID администраторов хранятся в `src/core/config.py` в переменной `ADMIN_IDS`.

331
docs/ADMIN_PANEL_TESTING.md Normal file
View File

@@ -0,0 +1,331 @@
# Тестирование Админ Панели
## Контрольный список для проверки кнопок
### ✅ Главное меню (для обычных пользователей)
- [ ] 🎲 Активные розыгрыши - показывает список активных розыгрышей
- [ ] 📝 Зарегистрироваться - открывает форму регистрации (только для незарегистрированных)
- [ ] Кнопка регистрации СКРЫТА для зарегистрированных пользователей
- [ ] Кнопка регистрации СКРЫТА для администраторов
### ✅ Главное меню (для администраторов)
- [ ] 🎲 Активные розыгрыши - показывает список активных розыгрышей
- [ ] ⚙️ Админ панель - открывает админ панель
- [ ] Создать розыгрыш - быстрое создание розыгрыша
---
## ✅ Админ панель - Главное меню
### Проверка открытия админ панели:
- [ ] Показывается краткая статистика (пользователи, розыгрыши, участия)
- [ ] Все 6 кнопок отображаются корректно
### Основные кнопки:
- [ ] 🎲 Управление розыгрышами (`admin_lotteries`)
- [ ] 👥 Управление участниками (`admin_participants`)
- [ ] 👑 Управление победителями (`admin_winners`)
- [ ] 📊 Статистика (`admin_stats`)
- [ ] ⚙️ Настройки (`admin_settings`)
- [ ] 🔙 Назад - возврат в главное меню бота
---
## ✅ Раздел: Управление розыгрышами
### Открытие раздела:
- [ ] Нажать "🎲 Управление розыгрышами" в админ панели
- [ ] Проверить отображение всех 7 кнопок
### Кнопки раздела:
- [ ] Создать розыгрыш
- [ ] Запускается процесс создания (FSM)
- [ ] Шаг 1: Ввод названия
- [ ] Шаг 2: Ввод описания
- [ ] Шаг 3: Ввод призов (через запятую)
- [ ] Шаг 4: Подтверждение
- [ ] Розыгрыш создается в БД
- [ ] Кнопка "❌ Отмена" работает на каждом шаге
- [ ] 📝 Редактировать розыгрыш
- [ ] Показывает список всех розыгрышей
- [ ] Выбор розыгрыша открывает меню редактирования
- [ ] Можно изменить название, описание, призы
- [ ] Изменения сохраняются в БД
- [ ] 🎭 Настройка отображения победителей
- [ ] Показывает список розыгрышей
- [ ] Для каждого розыгрыша можно выбрать тип отображения:
- [ ] По номеру счета
- [ ] По имени пользователя
- [ ] Настройка сохраняется
- [ ] 📋 Список всех розыгрышей
- [ ] Показывает все розыгрыши (активные и завершенные)
- [ ] Для каждого розыгрыша показывается:
- [ ] Название
- [ ] Статус (активный/завершенный)
- [ ] Количество участников
- [ ] Дата создания
- [ ] 🏁 Завершить розыгрыш
- [ ] Показывает список активных розыгрышей
- [ ] Выбор розыгрыша завершает его
- [ ] Запрос подтверждения
- [ ] Статус меняется в БД
- [ ] 🗑️ Удалить розыгрыш
- [ ] Показывает список всех розыгрышей
- [ ] Выбор розыгрыша запрашивает подтверждение
- [ ] Розыгрыш удаляется из БД
- [ ] 🔙 Назад - возврат в главное меню админ панели
---
## ✅ Раздел: Управление участниками
### Открытие раздела:
- [ ] Нажать "👥 Управление участниками" в админ панели
- [ ] Проверить отображение всех 9 кнопок
### Кнопки раздела:
- [ ] Добавить участника
- [ ] Выбор розыгрыша
- [ ] Выбор пользователя (по ID или имени)
- [ ] Участник добавляется в розыгрыш
- [ ] Проверка дубликатов
- [ ] 📥 Массовое добавление (ID)
- [ ] Выбор розыгрыша
- [ ] Ввод списка Telegram ID (через запятую или построчно)
- [ ] Массовое добавление участников
- [ ] Отчет об успешных/неудачных добавлениях
- [ ] 🏦 Массовое добавление (счета)
- [ ] Выбор розыгрыша
- [ ] Ввод списка номеров счетов
- [ ] Участники добавляются по номерам счетов
- [ ] Отчет об операции
- [ ] Удалить участника
- [ ] Выбор розыгрыша
- [ ] Выбор участника
- [ ] Участник удаляется
- [ ] Подтверждение удаления
- [ ] 📤 Массовое удаление (ID)
- [ ] Выбор розыгрыша
- [ ] Ввод списка ID для удаления
- [ ] Массовое удаление
- [ ] Отчет об операции
- [ ] 🏦 Массовое удаление (счета)
- [ ] Выбор розыгрыша
- [ ] Ввод списка номеров счетов
- [ ] Удаление по счетам
- [ ] Отчет об операции
- [ ] 👥 Все участники
- [ ] Показывает список всех зарегистрированных пользователей
- [ ] Пагинация (если много)
- [ ] Показывает ID, имя, статус регистрации
- [ ] 🔍 Поиск участников
- [ ] Поиск по имени
- [ ] Поиск по Telegram ID
- [ ] Поиск по номеру счета
- [ ] Показывает результаты поиска
- [ ] 📊 Участники по розыгрышам
- [ ] Выбор розыгрыша
- [ ] Показывает всех участников розыгрыша
- [ ] Количество участников
- [ ] Список с именами/ID/счетами
- [ ] 📈 Отчет по участникам
- [ ] Детальная статистика по участиям
- [ ] Топ участников (по количеству участий)
- [ ] Распределение по розыгрышам
- [ ] 🔙 Назад - возврат в главное меню админ панели
---
## ✅ Раздел: Управление победителями
### Открытие раздела:
- [ ] Нажать "👑 Управление победителями" в админ панели
- [ ] Проверить отображение всех 6 кнопок
### Кнопки раздела:
- [ ] 👑 Установить победителя
- [ ] Выбор розыгрыша
- [ ] Выбор места (1, 2, 3...)
- [ ] Выбор пользователя вручную
- [ ] Победитель сохраняется в БД
- [ ] 📝 Изменить победителя
- [ ] Показывает список розыгрышей с победителями
- [ ] Выбор победителя для изменения
- [ ] Возможность изменить место или пользователя
- [ ] Изменения сохраняются
- [ ] ❌ Удалить победителя
- [ ] Показывает список победителей
- [ ] Выбор победителя
- [ ] Подтверждение удаления
- [ ] Победитель удаляется из БД
- [ ] 📋 Список победителей
- [ ] Показывает всех победителей всех розыгрышей
- [ ] Группировка по розыгрышам
- [ ] Место, имя/счет, приз
- [ ] 🎲 Провести розыгрыш
- [ ] Выбор розыгрыша
- [ ] Автоматическое определение победителей
- [ ] Случайный выбор из участников
- [ ] Сохранение результатов
- [ ] Уведомление победителей
- [ ] 🔙 Назад - возврат в главное меню админ панели
---
## ✅ Раздел: Статистика
### Открытие раздела:
- [ ] Нажать "📊 Статистика" в админ панели
- [ ] Проверить отображение статистики
### Показываемые данные:
- [ ] 👥 Всего пользователей: [число]
- [ ] ✅ Зарегистрированных: [число]
- [ ] 🎲 Всего розыгрышей: [число]
- [ ] 🟢 Активных: [число]
- [ ] ✅ Завершенных: [число]
- [ ] 🎫 Участий: [число]
### Кнопки:
- [ ] 🔄 Обновить - обновляет статистику
- [ ] 🔙 Назад - возврат в админ панель
---
## ✅ Раздел: Настройки
### Открытие раздела:
- [ ] Нажать "⚙️ Настройки" в админ панели
- [ ] Проверить доступность настроек
### Возможности (зависит от реализации):
- [ ] Настройки уведомлений
- [ ] Экспорт данных
- [ ] Очистка старых данных
- [ ] Управление администраторами
- [ ] Системные настройки
- [ ] 🔙 Назад - возврат в админ панель
---
## ✅ Проверка прав доступа
### Для обычных пользователей:
- [ ] Кнопка "Админ панель" НЕ показывается
- [ ] Попытка прямого вызова admin callback'ов возвращает "❌ Недостаточно прав"
### Для администраторов:
- [ ] Все разделы доступны
- [ ] Все операции выполняются
- [ ] Статистика отображается корректно
---
## ✅ Проверка навигации
### Возврат назад:
- [ ] Из каждого подраздела можно вернуться в админ панель
- [ ] Из админ панели можно вернуться в главное меню
- [ ] Кнопки отмены работают во всех FSM состояниях
### Breadcrumbs (последовательность):
1. Главное меню бота
2. → Админ панель
3. → → Конкретный раздел (розыгрыши/участники/победители)
4. → → → Подменю раздела (если есть)
---
## 🐛 Известные проблемы и их решения
### Проблема: Кнопка не реагирует
**Решение:**
1. Проверить логи бота: `tail -f /tmp/bot_single.log`
2. Убедиться, что callback_data зарегистрирован в роутере
3. Проверить порядок подключения роутеров
### Проблема: FSM не сохраняет состояние
**Решение:**
1. Убедиться, что storage настроен (MemoryStorage)
2. Проверить вызов `state.set_state()`
3. Проверить StateFilter в handler'ах
### Проблема: "Недостаточно прав" для админа
**Решение:**
1. Проверить, что ID админа в `ADMIN_IDS` (config.py)
2. Проверить формат ID (должен быть int)
---
## 📝 Инструкция по тестированию
### Шаг 1: Подготовка
1. Запустить бота: `make bot-start`
2. Открыть чат с ботом в Telegram
3. Убедиться, что у вас админские права
### Шаг 2: Тестирование главного меню
1. Отправить `/start`
2. Проверить все кнопки главного меню
3. Проверить кнопку "Админ панель"
### Шаг 3: Тестирование админ панели
1. Открыть админ панель
2. Последовательно зайти в каждый раздел
3. Проверить все кнопки в каждом разделе
4. Отметить работающие кнопки в чеклисте
### Шаг 4: Тестирование FSM процессов
1. Создать новый розыгрыш (полный цикл)
2. Добавить участников (разными способами)
3. Провести розыгрыш
4. Проверить результаты
### Шаг 5: Проверка навигации
1. Из каждого меню вернуться назад
2. Проверить корректность возврата
3. Убедиться, что нет "мертвых" кнопок
### Шаг 6: Логи
1. Во время тестирования следить за логами
2. Фиксировать все ошибки
3. Проверять успешное выполнение операций
---
## ✅ Результат тестирования
**Дата:** [Указать дату]
**Тестировщик:** [Указать имя]
**Версия бота:** [Указать commit hash]
### Статистика:
- Всего проверено кнопок: ____ / ____
- Работает корректно: ____
- Требует исправления: ____
- Критические ошибки: ____
### Замечания:
[Описать найденные проблемы и рекомендации]

126
docs/CODE_CLEANUP_REPORT.md Normal file
View File

@@ -0,0 +1,126 @@
# Результаты очистки кода
## Дата: 17 ноября 2025 г.
### Выполненные действия:
## 1. Удалены дублирующиеся обработчики из main.py
**Удалено:**
- `test_callback_handler` - тестовый callback (не используется в продакшене)
- `admin_panel_handler` - дублируется с admin_panel.py
- `lottery_management_handler` - дублируется с admin_panel.py
- `conduct_lottery_admin_handler` - дублируется с admin_panel.py
- `conduct_specific_lottery_handler` - дублируется с admin_panel.py
**Оставлено:**
- `cmd_start` - обработчик команды /start
- `cmd_admin` - упрощен, теперь напрямую показывает админ панель
- `active_lotteries_handler` - показ активных розыгрышей
- `back_to_main_handler` - возврат в главное меню
## 2. Очищены методы BotController
**Удалено из `src/controllers/bot_controller.py`:**
- `handle_admin_panel()` - перенесено в admin_panel.py
- `handle_lottery_management()` - перенесено в admin_panel.py
- `handle_conduct_lottery_admin()` - перенесено в admin_panel.py
- `handle_conduct_lottery()` - перенесено в admin_panel.py
**Оставлено:**
- `handle_start()` - обработка команды /start
- `handle_active_lotteries()` - показ активных розыгрышей
- `is_admin()` - проверка прав администратора
## 3. Упрощены клавиатуры в ui.py
**Удалено из `src/components/ui.py`:**
- `get_lottery_management_keyboard()` - используется локальная версия в admin_panel.py
- `get_lottery_keyboard()` - не используется
- `get_conduct_lottery_keyboard()` - не используется
**Оставлено:**
- `get_main_keyboard()` - главная клавиатура бота
- `get_admin_keyboard()` - админская панель
## 4. Упрощены интерфейсы
**IBotController (`src/interfaces/base.py`):**
- Было: 6 методов
- Стало: 2 метода
- `handle_start()`
- `handle_active_lotteries()`
**IKeyboardBuilder (`src/interfaces/base.py`):**
- Было: 5 методов
- Стало: 2 метода
- `get_main_keyboard()`
- `get_admin_keyboard()`
## 5. Централизация логики
### Теперь вся админская логика находится в одном месте:
- **`src/handlers/admin_panel.py`** - все обработчики админ панели
- Создание розыгрышей
- Управление участниками
- Управление победителями
- Статистика
- Настройки
### Разделение ответственности:
**main.py** - основной роутер:
- Команды `/start`, `/admin`
- Базовые callback'и (`active_lotteries`, `back_to_main`)
**admin_panel.py** - админ роутер:
- Все callback'и начинающиеся с `admin_*`
- FSM состояния для многошаговых операций
- Вся логика управления
**bot_controller.py** - бизнес-логика:
- Работа с сервисами
- Форматирование данных
- Проверка прав доступа
**ui.py** - UI компоненты:
- Построение клавиатур
- Форматирование сообщений
## 6. Результаты
### Статистика удалений:
- **Удалено строк кода:** 202
- **Добавлено строк:** 21
- **Чистый результат:** -181 строка
### Улучшения:
✅ Нет дублирующегося кода
✅ Четкое разделение ответственности
✅ Упрощенные интерфейсы
✅ Лучшая поддерживаемость
✅ Меньше потенциальных багов
### Тестирование:
✅ Бот запускается без ошибок (PID: 802748)
Все роутеры подключены правильно
✅ Логика админ панели централизована
## 7. Коммиты
1. **0fdf01d** - feat: update admin panel keyboard structure and registration button logic
2. **43d46ea** - refactor: clean up unused code and duplicate handlers
## 8. Следующие шаги (рекомендации)
1. Протестировать все кнопки админ панели через Telegram
2. Использовать `ADMIN_PANEL_TESTING.md` как чек-лист
3. Проверить работу FSM состояний
4. Убедиться в корректности навигации
## 9. Примечания
- Все изменения обратно совместимы
- Логика работы не изменилась, только структура
- Бот работает стабильно
- Код стал чище и понятнее

118
docs/DEPLOY_QUICKSTART.md Normal file
View File

@@ -0,0 +1,118 @@
# 🚀 Быстрый деплой бота с внешним PostgreSQL
## Шаг 0: Установка Docker (если не установлен)
```bash
# Проверка Docker
docker --version
docker compose version
# Если не установлен - см. DOCKER_INSTALL.md
# Или быстрая установка (Ubuntu/Debian):
sudo apt update
sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker
# Проверка
make docker-check
```
## Шаг 1: Подготовка PostgreSQL
```bash
# Подключитесь к PostgreSQL
psql -U postgres
# Создайте пользователя и БД
CREATE USER bot_user WITH PASSWORD 'secure_password_here';
CREATE DATABASE lottery_bot OWNER bot_user;
GRANT ALL PRIVILEGES ON DATABASE lottery_bot TO bot_user;
# Выход
\q
```
## Шаг 2: Настройка .env.prod
```bash
# Скопируйте пример
cp .env.prod.example .env.prod
# Отредактируйте .env.prod
nano .env.prod
```
**Заполните:**
```env
# Telegram
BOT_TOKEN=your_bot_token_from_botfather
ADMIN_IDS=123456789,987654321
# PostgreSQL (замените на свои данные)
DATABASE_URL=postgresql+asyncpg://bot_user:secure_password@localhost:5432/lottery_bot
```
## Шаг 3: Деплой
### Вариант A: Docker (рекомендуется)
```bash
# Билд и запуск
make docker-deploy
# Или вручную:
docker-compose build
docker-compose up -d
docker exec -it lottery_bot alembic upgrade head
```
### Вариант B: Локально
```bash
# Установка
make install
# Миграции
source .venv/bin/activate
alembic upgrade head
# Запуск
make bot-start
```
## Шаг 4: Проверка
```bash
# Проверить подключение к БД
make docker-test-db
# Логи
make docker-logs
# Статус
make docker-status
```
## 📋 Полезные команды
```bash
# Остановка
docker-compose down
# Перезапуск
docker-compose restart
# Логи в реальном времени
docker-compose logs -f bot
# Бэкап БД
pg_dump -U bot_user lottery_bot > backup.sql
# Восстановление БД
psql -U bot_user lottery_bot < backup.sql
```
## 🔥 Проблемы?
См. [EXTERNAL_DB_SETUP.md](./EXTERNAL_DB_SETUP.md) раздел "Troubleshooting"

281
docs/DOCKER_DEPLOY.md Normal file
View File

@@ -0,0 +1,281 @@
# 🐳 Docker Deployment Guide
## Быстрый старт
### 1. Настройка окружения
```bash
make docker-setup
```
Отредактируйте `.env.prod` и укажите:
- `BOT_TOKEN` - токен от @BotFather
- `POSTGRES_PASSWORD` - надежный пароль для БД
- `DATABASE_URL` - обновите пароль в строке подключения
- `ADMIN_IDS` - ваш Telegram ID
### 2. Развертывание
```bash
# Автоматическое развертывание
make docker-deploy
# Или вручную:
make docker-build
make docker-up
make docker-db-migrate
```
### 3. Проверка
```bash
make docker-status
make docker-logs
```
---
## Основные команды
### Управление контейнерами
```bash
make docker-up # Запустить контейнеры
make docker-down # Остановить контейнеры
make docker-restart # Перезапустить контейнеры
make docker-status # Статус контейнеров
```
### Просмотр логов
```bash
make docker-logs # Логи бота (с отслеживанием)
make docker-logs-db # Логи базы данных
make docker-logs-all # Все логи
```
### База данных
```bash
make docker-db-migrate # Применить миграции
make docker-db-shell # Подключиться к PostgreSQL
make docker-db-backup # Создать бэкап
make docker-db-restore BACKUP=backups/backup_20231115.sql
```
### Разработка
```bash
make docker-shell # Открыть shell в контейнере бота
make docker-rebuild # Пересобрать и перезапустить
```
### Очистка
```bash
make docker-clean # Удалить контейнеры
make docker-prune # Полная очистка (включая volumes)
```
---
## Структура проекта
```
lottery_bot/
├── Dockerfile # Образ бота
├── docker-compose.yml # Оркестрация контейнеров
├── .env.prod # Продакшн-конфигурация (НЕ коммитить!)
├── .env.prod.example # Пример конфигурации
├── .dockerignore # Исключения для Docker
├── deploy.sh # Скрипт автоматического развертывания
├── logs/ # Логи (монтируется из контейнера)
├── backups/ # Бэкапы БД
└── data/ # Данные приложения
```
---
## Архитектура
### Контейнеры
**bot** - Telegram бот
- Образ: Собирается из `Dockerfile`
- Restart: unless-stopped
- Зависимости: db
- Health check: Python проверка
**db** - PostgreSQL база данных
- Образ: postgres:15-alpine
- Restart: unless-stopped
- Порты: 5432:5432
- Volume: postgres_data
- Health check: pg_isready
### Volumes
- `postgres_data` - Данные PostgreSQL (персистентные)
- `bot_data` - Данные приложения
### Networks
- `lottery_network` - Внутренняя сеть для связи контейнеров
---
## Мониторинг
### Статус контейнеров
```bash
docker-compose ps
# Ожидаемый вывод:
# lottery_bot running 0.0.0.0:->
# lottery_db running 0.0.0.0:5432->5432/tcp
```
### Логи в реальном времени
```bash
docker-compose logs -f bot
```
### Использование ресурсов
```bash
docker stats lottery_bot lottery_db
```
---
## Бэкапы
### Автоматический бэкап
```bash
# Создать бэкап с временной меткой
make docker-db-backup
# Файл будет сохранен в:
# backups/backup_YYYYMMDD_HHMMSS.sql
```
### Восстановление
```bash
make docker-db-restore BACKUP=backups/backup_20231115_120000.sql
```
### Настройка автоматических бэкапов (cron)
```bash
# Добавьте в crontab:
0 2 * * * cd /path/to/lottery_bot && make docker-db-backup
```
---
## Обновление
### Обновление кода
```bash
git pull
make docker-rebuild
```
### Применение миграций
```bash
make docker-db-migrate
```
---
## Troubleshooting
### Контейнер не запускается
```bash
# Проверьте логи
make docker-logs
# Проверьте конфигурацию
cat .env.prod
# Пересоберите образ
make docker-rebuild
```
### База данных недоступна
```bash
# Проверьте статус БД
docker-compose ps db
# Проверьте логи БД
make docker-logs-db
# Подключитесь к БД напрямую
make docker-db-shell
```
### Проблемы с миграциями
```bash
# Проверьте текущую версию
docker-compose exec bot alembic current
# Откатите миграцию
docker-compose exec bot alembic downgrade -1
# Примените снова
make docker-db-migrate
```
### Высокое потребление ресурсов
```bash
# Проверьте использование
docker stats
# Ограничьте ресурсы в docker-compose.yml:
services:
bot:
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
```
---
## Безопасность
### Рекомендации
1. **Пароли**
- Используйте надежные пароли в `.env.prod`
- Не коммитьте `.env.prod` в Git
2. **Порты**
- Закройте порт 5432 если БД не нужна извне
- Используйте firewall для ограничения доступа
3. **Обновления**
- Регулярно обновляйте образы:
```bash
docker-compose pull
make docker-rebuild
```
4. **Логи**
- Ротация логов в production
- Настройте logrotate для `/home/trevor/new_lottery_bot/logs/`
5. **Бэкапы**
- Автоматические ежедневные бэкапы
- Храните бэкапы в безопасном месте
---
## Production Checklist
- [ ] Отредактирован `.env.prod` с реальными значениями
- [ ] Установлены надежные пароли
- [ ] Настроены автоматические бэкапы
- [ ] Настроен мониторинг и алерты
- [ ] Настроена ротация логов
- [ ] Закрыты неиспользуемые порты
- [ ] Протестирован процесс восстановления из бэкапа
- [ ] Документированы учетные данные администраторов
---
## Полезные ссылки
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [PostgreSQL Docker Hub](https://hub.docker.com/_/postgres)
- [Alembic Documentation](https://alembic.sqlalchemy.org/)

170
docs/DOCKER_INSTALL.md Normal file
View File

@@ -0,0 +1,170 @@
# Установка Docker и Docker Compose
## Для Ubuntu/Debian
### Установка Docker
```bash
# Обновление системы
sudo apt update
sudo apt upgrade -y
# Установка зависимостей
sudo apt install -y ca-certificates curl gnupg lsb-release
# Добавление GPG ключа Docker
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Добавление репозитория Docker
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Установка Docker Engine
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Проверка установки
docker --version
docker compose version
```
### Настройка прав (опционально)
```bash
# Добавить пользователя в группу docker (чтобы не использовать sudo)
sudo usermod -aG docker $USER
# Применить изменения (нужно перелогиниться или выполнить)
newgrp docker
# Проверка
docker ps
```
### Автозапуск Docker
```bash
sudo systemctl enable docker
sudo systemctl start docker
```
## Для других систем
### CentOS/RHEL/Fedora
```bash
# Установка Docker
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Запуск
sudo systemctl start docker
sudo systemctl enable docker
```
### Debian
```bash
# Для Debian используйте те же команды что и для Ubuntu
# Но в добавлении репозитория используйте:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```
## Проверка установки
```bash
# Версия Docker
docker --version
# Должно вывести: Docker version 24.0.x или новее
# Версия Docker Compose
docker compose version
# Должно вывести: Docker Compose version v2.x.x или новее
# Тест Docker
docker run hello-world
```
## Если Docker Compose v1 (старая версия)
Если у вас установлен `docker-compose` (v1) вместо `docker compose` (v2):
```bash
# Удалите старую версию
sudo apt remove docker-compose
# Установите плагин compose
sudo apt install docker-compose-plugin
# Проверка
docker compose version
```
## Troubleshooting
### Ошибка: "Cannot connect to the Docker daemon"
```bash
# Запустите Docker
sudo systemctl start docker
# Проверьте статус
sudo systemctl status docker
```
### Ошибка: "permission denied"
```bash
# Добавьте пользователя в группу docker
sudo usermod -aG docker $USER
# Перелогиньтесь или выполните
newgrp docker
```
### Ошибка: "docker-compose: command not found" но Docker Compose установлен
Makefile автоматически определит правильную команду:
- `docker compose` (v2, рекомендуется)
- `docker-compose` (v1, устаревшая)
## Полезные команды
```bash
# Информация о Docker
docker info
# Список запущенных контейнеров
docker ps
# Список всех контейнеров
docker ps -a
# Список образов
docker images
# Очистка неиспользуемых ресурсов
docker system prune -a
# Логи контейнера
docker logs container_name
# Остановить все контейнеры
docker stop $(docker ps -aq)
# Удалить все контейнеры
docker rm $(docker ps -aq)
```
## Обновление Docker
```bash
sudo apt update
sudo apt upgrade docker-ce docker-ce-cli containerd.io docker-compose-plugin
```

162
docs/EXTERNAL_DB_SETUP.md Normal file
View File

@@ -0,0 +1,162 @@
# Настройка внешнего PostgreSQL
Этот гайд описывает как настроить бота для работы с внешним PostgreSQL сервером.
## Предварительные требования
1. Запущенный PostgreSQL сервер (версия 13+)
2. Доступ к серверу по сети (если сервер на другой машине)
3. Созданная база данных для бота
## Шаг 1: Подготовка PostgreSQL
### Создание базы данных и пользователя
```sql
-- Подключитесь к PostgreSQL
psql -U postgres
-- Создайте пользователя
CREATE USER bot_user WITH PASSWORD 'your_secure_password';
-- Создайте базу данных
CREATE DATABASE lottery_bot OWNER bot_user;
-- Выдайте права
GRANT ALL PRIVILEGES ON DATABASE lottery_bot TO bot_user;
```
### Настройка доступа (если PostgreSQL на другом сервере)
Отредактируйте `postgresql.conf`:
```conf
listen_addresses = '*' # или конкретный IP
```
Отредактируйте `pg_hba.conf`:
```conf
# Разрешить подключение с определенного IP
host lottery_bot bot_user 192.168.1.0/24 md5
```
Перезапустите PostgreSQL:
```bash
sudo systemctl restart postgresql
```
## Шаг 2: Настройка .env.prod
Отредактируйте `.env.prod`:
```env
# PostgreSQL настройки
POSTGRES_HOST=your_db_server_ip_or_domain
POSTGRES_PORT=5432
POSTGRES_DB=lottery_bot
POSTGRES_USER=bot_user
POSTGRES_PASSWORD=your_secure_password
# Database URL
DATABASE_URL=postgresql+asyncpg://bot_user:your_secure_password@your_db_server_ip_or_domain:5432/lottery_bot
```
### Примеры DATABASE_URL
**Локальная БД:**
```
DATABASE_URL=postgresql+asyncpg://bot_user:password@localhost:5432/lottery_bot
```
**Удаленная БД:**
```
DATABASE_URL=postgresql+asyncpg://bot_user:password@192.168.1.100:5432/lottery_bot
```
**С доменом:**
```
DATABASE_URL=postgresql+asyncpg://bot_user:password@db.example.com:5432/lottery_bot
```
**Через Docker network (если БД в другом контейнере):**
```
DATABASE_URL=postgresql+asyncpg://bot_user:password@postgres_container:5432/lottery_bot
```
## Шаг 3: Применение миграций
После настройки подключения примените миграции:
```bash
# Активируйте виртуальное окружение
source .venv/bin/activate
# Примените миграции
alembic upgrade head
```
## Шаг 4: Запуск бота
### С Docker Compose:
```bash
docker-compose up -d
```
### Локально:
```bash
make bot-start
```
## Проверка подключения
Проверьте подключение к БД:
```bash
# Из контейнера
docker exec -it lottery_bot python -c "from src.core.database import engine; import asyncio; asyncio.run(engine.dispose())"
# Локально
python -c "from src.core.database import engine; import asyncio; asyncio.run(engine.dispose())"
```
## Troubleshooting
### Ошибка: "FATAL: password authentication failed"
- Проверьте правильность пароля в DATABASE_URL
- Убедитесь что пользователь создан в PostgreSQL
- Проверьте настройки pg_hba.conf
### Ошибка: "could not connect to server"
- Проверьте что PostgreSQL запущен
- Убедитесь что порт 5432 открыт (firewall)
- Проверьте listen_addresses в postgresql.conf
### Ошибка: "database does not exist"
- Создайте базу данных (см. Шаг 1)
- Проверьте имя БД в DATABASE_URL
### Ошибка: "SSL connection has been closed unexpectedly"
- Добавьте `?ssl=require` или `?ssl=prefer` в конец DATABASE_URL
- Или отключите SSL: `?ssl=false`
## Рекомендации по безопасности
1. **Используйте сильные пароли** для пользователя БД
2. **Ограничьте доступ** только с нужных IP (pg_hba.conf)
3. **Используйте SSL** для подключения к удаленной БД
4. **Регулярно делайте бэкапы**:
```bash
pg_dump -U bot_user lottery_bot > backup_$(date +%Y%m%d).sql
```
5. **Не коммитьте .env.prod** в git (добавлен в .gitignore)
## Мониторинг
Проверка состояния подключений:
```sql
SELECT * FROM pg_stat_activity WHERE datname = 'lottery_bot';
```
Размер базы данных:
```sql
SELECT pg_size_pretty(pg_database_size('lottery_bot'));
```

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""
Исправление схемы базы данных
Добавление недостающих полей в таблицу users
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from src.core.database import engine
from sqlalchemy import text
async def fix_database_schema():
"""Добавление недостающих полей в базу данных"""
print("🔧 Исправляем схему базы данных...")
async with engine.begin() as conn:
# Проверяем, есть ли поле phone
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'users' AND column_name = 'phone'"
))
if not result.fetchone():
print("📞 Добавляем поле 'phone'...")
await conn.execute(text(
"ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL"
))
print("✅ Поле 'phone' добавлено")
else:
print("✅ Поле 'phone' уже существует")
# Проверяем, есть ли поле club_card_number
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'users' AND column_name = 'club_card_number'"
))
if not result.fetchone():
print("💳 Добавляем поле 'club_card_number'...")
await conn.execute(text(
"ALTER TABLE users ADD COLUMN club_card_number VARCHAR(50) NULL"
))
await conn.execute(text(
"CREATE UNIQUE INDEX ix_users_club_card_number ON users (club_card_number)"
))
print("✅ Поле 'club_card_number' добавлено")
else:
print("✅ Поле 'club_card_number' уже существует")
# Проверяем, есть ли поле is_registered
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'users' AND column_name = 'is_registered'"
))
if not result.fetchone():
print("📝 Добавляем поле 'is_registered'...")
await conn.execute(text(
"ALTER TABLE users ADD COLUMN is_registered BOOLEAN DEFAULT FALSE NOT NULL"
))
print("✅ Поле 'is_registered' добавлено")
else:
print("✅ Поле 'is_registered' уже существует")
# Проверяем, есть ли поле verification_code
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'users' AND column_name = 'verification_code'"
))
if not result.fetchone():
print("🔐 Добавляем поле 'verification_code'...")
await conn.execute(text(
"ALTER TABLE users ADD COLUMN verification_code VARCHAR(10) NULL"
))
await conn.execute(text(
"CREATE UNIQUE INDEX ix_users_verification_code ON users (verification_code)"
))
print("✅ Поле 'verification_code' добавлено")
else:
print("✅ Поле 'verification_code' уже существует")
# Удаляем поле account_number, если оно есть (оно перенесено в отдельную таблицу)
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'users' AND column_name = 'account_number'"
))
if result.fetchone():
print("🗑️ Удаляем устаревшее поле 'account_number'...")
# Сначала удаляем индекс
try:
await conn.execute(text("DROP INDEX IF EXISTS ix_users_account_number"))
except:
pass
await conn.execute(text(
"ALTER TABLE users DROP COLUMN account_number"
))
print("✅ Поле 'account_number' удалено")
else:
print("✅ Поле 'account_number' уже удалено")
async def main():
"""Основная функция"""
try:
await fix_database_schema()
print("\n🎉 Схема базы данных успешно исправлена!")
except Exception as e:
print(f"❌ Ошибка при исправлении базы данных: {e}")
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

92
main.py
View File

@@ -22,6 +22,8 @@ from src.handlers.redraw_handlers import router as redraw_router
from src.handlers.chat_handlers import router as chat_router
from src.handlers.admin_chat_handlers import router as admin_chat_router
from src.handlers.account_handlers import account_router
from src.handlers.message_management import message_admin_router
from src.handlers.p2p_chat import router as p2p_chat_router
# Настройка логирования
logging.basicConfig(
@@ -37,6 +39,16 @@ dp = Dispatcher(storage=storage)
router = Router()
# Middleware для логирования всех callback'ов
@dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов"""
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
result = await handler(event, data)
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
return result
@asynccontextmanager
async def get_controller():
"""Контекстный менеджер для получения контроллера с БД сессией"""
@@ -57,53 +69,25 @@ async def cmd_start(message: Message):
@router.message(Command("admin"))
async def cmd_admin(message: Message):
"""Обработчик команды /admin"""
async with get_controller() as controller:
if not controller.is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав для доступа к админ панели")
return
# Создаем callback query объект для совместимости
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="admin_cmd",
from_user=message.from_user,
chat_instance="admin",
data="admin_panel",
message=message
)
await controller.handle_admin_panel(fake_callback)
"""Обработчик команды /admin - перенаправляет в admin_panel"""
from src.core.config import ADMIN_IDS
if message.from_user.id not in ADMIN_IDS:
await message.answer("❌ Недостаточно прав для доступа к админ панели")
return
# Отправляем сообщение с кнопкой админ панели
from src.components.ui import KeyboardBuilderImpl
kb = KeyboardBuilderImpl()
keyboard = kb.get_admin_keyboard()
text = "⚙️ **Панель администратора**\n\n"
text += "Выберите раздел для управления:"
await message.answer(text, reply_markup=keyboard, parse_mode="Markdown")
# === CALLBACK HANDLERS ===
@router.callback_query(F.data == "test_callback")
async def test_callback_handler(callback: CallbackQuery):
"""Тестовый callback handler"""
await callback.answer("✅ Тест прошел успешно! Колбэки работают.", show_alert=True)
@router.callback_query(F.data == "admin_panel")
async def admin_panel_handler(callback: CallbackQuery):
"""Обработчик админ панели"""
async with get_controller() as controller:
await controller.handle_admin_panel(callback)
@router.callback_query(F.data == "lottery_management")
async def lottery_management_handler(callback: CallbackQuery):
"""Обработчик управления розыгрышами"""
async with get_controller() as controller:
await controller.handle_lottery_management(callback)
@router.callback_query(F.data == "conduct_lottery_admin")
async def conduct_lottery_admin_handler(callback: CallbackQuery):
"""Обработчик выбора розыгрыша для проведения"""
async with get_controller() as controller:
await controller.handle_conduct_lottery_admin(callback)
@router.callback_query(F.data == "active_lotteries")
async def active_lotteries_handler(callback: CallbackQuery):
"""Обработчик показа активных розыгрышей"""
@@ -111,23 +95,11 @@ async def active_lotteries_handler(callback: CallbackQuery):
await controller.handle_active_lotteries(callback)
@router.callback_query(F.data.startswith("conduct_") & ~F.data.in_(["conduct_lottery_admin"]))
async def conduct_specific_lottery_handler(callback: CallbackQuery):
"""Обработчик проведения конкретного розыгрыша"""
async with get_controller() as controller:
await controller.handle_conduct_lottery(callback)
@router.callback_query(F.data == "back_to_main")
async def back_to_main_handler(callback: CallbackQuery):
"""Обработчик возврата в главное меню"""
async with get_controller() as controller:
await controller.handle_start(callback.message)
# === ЗАГЛУШКИ ДЛЯ ОСТАЛЬНЫХ CALLBACKS ===
# === ЗАГЛУШКИ НЕ НУЖНЫ - ВСЕ ФУНКЦИИ РЕАЛИЗОВАНЫ В РОУТЕРАХ ===
# Функции обрабатываются в:
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
# - registration_handlers.py: регистрация пользователей
@@ -149,15 +121,19 @@ async def main():
dp.include_router(router)
# 2. Специфичные роутеры
dp.include_router(message_admin_router) # Управление сообщениями администратором
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
dp.include_router(registration_router) # Регистрация
dp.include_router(admin_account_router) # Админские команды счетов
dp.include_router(admin_chat_router) # Админские команды чата
dp.include_router(redraw_router) # Повторные розыгрыши
dp.include_router(account_router) # Пользовательские счета
dp.include_router(p2p_chat_router) # P2P чат между пользователями
# 3. Chat router ПОСЛЕДНИМ (ловит все необработанные сообщения)
dp.include_router(chat_router) # Пользовательский чат (последним - ловит все сообщения)
# 3. Chat router для broadcast (ловит все необработанные сообщения)
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
# 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов)
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
# Запускаем polling
try:

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
Минимальная рабочая версия main.py для лотерейного бота
"""
from aiogram import Bot, Dispatcher
from aiogram.types import BotCommand
from aiogram.fsm.storage.memory import MemoryStorage
import asyncio
import logging
import signal
import sys
from src.core.config import BOT_TOKEN, ADMIN_IDS
from src.core.database import async_session_maker, init_db
# Настройка логирования
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Инициализация бота
bot = Bot(token=BOT_TOKEN)
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
async def set_commands():
"""Установка команд бота"""
commands = [
BotCommand(command="start", description="🚀 Запустить бота"),
BotCommand(command="help", description="❓ Помощь"),
]
await bot.set_my_commands(commands)
async def main():
"""Главная функция"""
try:
logger.info("🔄 Инициализация базы данных...")
await init_db()
logger.info("🔄 Установка команд...")
await set_commands()
# Импортируем и подключаем роутеры
logger.info("🔄 Подключение роутеров...")
try:
from src.handlers.registration_handlers import router as registration_router
dp.include_router(registration_router)
logger.info("✅ Registration router подключен")
except Exception as e:
logger.error(f"❌ Ошибка подключения registration router: {e}")
try:
from src.handlers.admin_panel import admin_router
dp.include_router(admin_router)
logger.info("✅ Admin router подключен")
except Exception as e:
logger.error(f"❌ Ошибка подключения admin router: {e}")
try:
from src.handlers.account_handlers import account_router
dp.include_router(account_router)
logger.info("✅ Account router подключен")
except Exception as e:
logger.error(f"❌ Ошибка подключения account router: {e}")
# Обработка сигналов для graceful shutdown
def signal_handler():
logger.info("Получен сигнал завершения, остановка бота...")
# Настройка обработчиков сигналов
if sys.platform != "win32":
for sig in (signal.SIGTERM, signal.SIGINT):
asyncio.get_event_loop().add_signal_handler(sig, signal_handler)
# Получаем информацию о боте
bot_info = await bot.get_me()
logger.info(f"🚀 Бот запущен: @{bot_info.username} ({bot_info.first_name})")
# Запуск бота
await dp.start_polling(bot)
except Exception as e:
logger.error(f"Критическая ошибка: {e}")
import traceback
traceback.print_exc()
finally:
logger.info("Завершение работы")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Бот остановлен пользователем")
except Exception as e:
logger.error(f"Критическая ошибка: {e}")
finally:
logger.info("Завершение работы")

View File

@@ -0,0 +1,53 @@
"""add p2p messages table
Revision ID: 008
Revises: 007
Create Date: 2025-11-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision = '008'
down_revision = '007'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Создаём таблицу P2P сообщений
op.create_table(
'p2p_messages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sender_id', sa.Integer(), nullable=False),
sa.Column('recipient_id', sa.Integer(), nullable=False),
sa.Column('message_type', sa.String(length=20), nullable=False),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('file_id', sa.String(length=255), nullable=True),
sa.Column('sender_message_id', sa.Integer(), nullable=False),
sa.Column('recipient_message_id', sa.Integer(), nullable=True),
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reply_to_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['reply_to_id'], ['p2p_messages.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Создаём индексы для быстрого поиска
op.create_index('ix_p2p_messages_sender_id', 'p2p_messages', ['sender_id'])
op.create_index('ix_p2p_messages_recipient_id', 'p2p_messages', ['recipient_id'])
op.create_index('ix_p2p_messages_is_read', 'p2p_messages', ['is_read'])
op.create_index('ix_p2p_messages_created_at', 'p2p_messages', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_p2p_messages_created_at', 'p2p_messages')
op.drop_index('ix_p2p_messages_is_read', 'p2p_messages')
op.drop_index('ix_p2p_messages_recipient_id', 'p2p_messages')
op.drop_index('ix_p2p_messages_sender_id', 'p2p_messages')
op.drop_table('p2p_messages')

View File

@@ -8,18 +8,20 @@ from src.core.models import Lottery, Winner
class KeyboardBuilderImpl(IKeyboardBuilder):
"""Реализация построителя клавиатур"""
def get_main_keyboard(self, is_admin: bool = False):
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
"""Получить главную клавиатуру"""
buttons = [
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
[InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")],
[InlineKeyboardButton(text="🧪 ТЕСТ КОЛБЭК", callback_data="test_callback")]
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")]
]
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
if not is_admin and not is_registered:
buttons.append([InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")])
if is_admin:
buttons.extend([
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="create_lottery")]
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="admin_create_lottery")]
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -27,40 +29,19 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
def get_admin_keyboard(self):
"""Получить админскую клавиатуру"""
buttons = [
[
InlineKeyboardButton(text="👥 Пользователи", callback_data="user_management"),
InlineKeyboardButton(text="💳 Счета", callback_data="account_management")
],
[
InlineKeyboardButton(text="🎯 Розыгрыши", callback_data="lottery_management"),
InlineKeyboardButton(text="💬 Чат", callback_data="chat_management")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="stats"),
InlineKeyboardButton(text="⚙️ Настройки", callback_data="settings")
],
[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_settings")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_lottery_management_keyboard(self):
"""Получить клавиатуру управления розыгрышами"""
buttons = [
[
InlineKeyboardButton(text="📋 Все розыгрыши", callback_data="all_lotteries"),
InlineKeyboardButton(text="🎲 Активные", callback_data="active_lotteries_admin")
],
[
InlineKeyboardButton(text="✅ Завершенные", callback_data="completed_lotteries"),
InlineKeyboardButton(text=" Создать", callback_data="create_lottery")
],
[
InlineKeyboardButton(text="🎯 Провести розыгрыш", callback_data="conduct_lottery_admin"),
InlineKeyboardButton(text="🔄 Переросыгрыш", callback_data="admin_redraw")
],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
class MessageFormatterImpl(IMessageFormatter):
"""Реализация форматирования сообщений"""
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
"""Получить клавиатуру для конкретного розыгрыша"""

View File

@@ -49,77 +49,19 @@ class BotController(IBotController):
else:
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
keyboard = self.keyboard_builder.get_main_keyboard(self.is_admin(message.from_user.id))
keyboard = self.keyboard_builder.get_main_keyboard(
is_admin=self.is_admin(message.from_user.id),
is_registered=user.is_registered
)
await message.answer(
welcome_text,
reply_markup=keyboard
)
async def handle_admin_panel(self, callback: CallbackQuery):
"""Обработать админ панель"""
if not self.is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
text = "⚙️ **Панель администратора**\n\n"
text += "Выберите раздел для управления:"
keyboard = self.keyboard_builder.get_admin_keyboard()
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
async def handle_lottery_management(self, callback: CallbackQuery):
"""Обработать управление розыгрышами"""
if not self.is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
text = "🎯 **Управление розыгрышами**\n\n"
text += "Выберите действие:"
keyboard = self.keyboard_builder.get_lottery_management_keyboard()
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
async def handle_conduct_lottery_admin(self, callback: CallbackQuery):
"""Обработать выбор розыгрыша для проведения"""
if not self.is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
# Получаем активные розыгрыши
lotteries = await self.lottery_service.get_active_lotteries()
if not lotteries:
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
return
text = "🎯 **Выберите розыгрыш для проведения:**\n\n"
for lottery in lotteries:
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
text += f"🎲 {lottery.title} ({participants_count} участников)\n"
keyboard = self.keyboard_builder.get_conduct_lottery_keyboard(lotteries)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
async def handle_active_lotteries(self, callback: CallbackQuery):
"""Показать активные розыгрыши"""
lotteries = await self.lottery_service.get_active_lotteries()
lotteries = await self.lottery_repo.get_active()
if not lotteries:
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
@@ -132,46 +74,34 @@ class BotController(IBotController):
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
text += lottery_info + "\n" + "="*30 + "\n\n"
keyboard = self.keyboard_builder.get_main_keyboard(self.is_admin(callback.from_user.id))
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
# Получаем информацию о регистрации пользователя
user = await self.user_service.get_or_create_user(
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name
)
keyboard = self.keyboard_builder.get_main_keyboard(
is_admin=self.is_admin(callback.from_user.id),
is_registered=user.is_registered
)
async def handle_conduct_lottery(self, callback: CallbackQuery):
"""Провести конкретный розыгрыш"""
if not self.is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
try:
lottery_id = int(callback.data.split("_")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный формат данных", show_alert=True)
return
# Проводим розыгрыш
results = await self.lottery_service.conduct_draw(lottery_id)
if not results:
await callback.answer("Не удалось провести розыгрыш", show_alert=True)
return
# Форматируем результаты
text = "🎉 **Розыгрыш завершен!**\n\n"
winners = [result['winner'] for result in results.values()]
winners_text = self.message_formatter.format_winners_list(winners)
text += winners_text
keyboard = self.keyboard_builder.get_admin_keyboard()
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
await callback.answer("✅ Розыгрыш успешно проведен!", show_alert=True)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback
if "message is not modified" in str(e):
await callback.answer("✅ Уже показаны активные розыгрыши")
else:
# Другие ошибки - пробуем отправить новое сообщение
await callback.answer()
await callback.message.answer(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)

View File

@@ -1,6 +1,6 @@
"""Сервисы для системы чата"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, update, delete
from sqlalchemy import select, and_, or_, update, delete, text
from sqlalchemy.orm import selectinload
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
@@ -185,6 +185,52 @@ class ChatMessageService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_message_by_telegram_id(
session: AsyncSession,
telegram_message_id: int,
user_id: Optional[int] = None
) -> Optional[ChatMessage]:
"""
Получить сообщение по telegram_message_id
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
"""
# Сначала ищем по оригинальному telegram_message_id
query = select(ChatMessage).where(
ChatMessage.telegram_message_id == telegram_message_id
)
if user_id:
query = query.where(ChatMessage.user_id == user_id)
result = await session.execute(query)
message = result.scalar_one_or_none()
# Если нашли - возвращаем
if message:
return message
# Если не нашли - ищем в forwarded_message_ids
# Загружаем все недавние сообщения и ищем в них
query = select(ChatMessage).where(
ChatMessage.forwarded_message_ids.isnot(None)
).order_by(ChatMessage.created_at.desc()).limit(100)
result = await session.execute(query)
messages = result.scalars().all()
# Ищем сообщение, где telegram_message_id есть в forwarded_message_ids
for msg in messages:
if msg.forwarded_message_ids:
for user_tid, fwd_msg_id in msg.forwarded_message_ids.items():
if fwd_msg_id == telegram_message_id:
return msg
return None
result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_user_messages(
session: AsyncSession,
@@ -238,6 +284,58 @@ class ChatMessageService:
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def get_user_messages_all(
session: AsyncSession,
limit: int = 50,
offset: int = 0,
include_deleted: bool = False
) -> List[ChatMessage]:
"""Получить последние сообщения всех пользователей"""
query = select(ChatMessage).options(selectinload(ChatMessage.sender))
if not include_deleted:
query = query.where(ChatMessage.is_deleted == False)
query = query.order_by(ChatMessage.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def count_messages(
session: AsyncSession,
include_deleted: bool = False
) -> int:
"""Подсчитать количество сообщений"""
from sqlalchemy import func
query = select(func.count(ChatMessage.id))
if not include_deleted:
query = query.where(ChatMessage.is_deleted == False)
result = await session.execute(query)
return result.scalar() or 0
@staticmethod
async def mark_as_deleted(
session: AsyncSession,
message_id: int,
deleted_by: int
) -> bool:
"""Пометить сообщение как удаленное"""
result = await session.execute(
update(ChatMessage)
.where(ChatMessage.id == message_id)
.values(
is_deleted=True,
deleted_by=deleted_by,
deleted_at=datetime.now(timezone.utc)
)
)
await session.commit()
return result.rowcount > 0
class ChatPermissionService:

View File

@@ -215,4 +215,30 @@ class ChatMessage(Base):
moderator = relationship("User", foreign_keys=[deleted_by])
def __repr__(self):
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
class P2PMessage(Base):
"""P2P сообщения между пользователями"""
__tablename__ = "p2p_messages"
id = Column(Integer, primary_key=True)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
recipient_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
message_type = Column(String(20), nullable=False) # text, photo, video, etc.
text = Column(Text, nullable=True)
file_id = Column(String(255), nullable=True)
sender_message_id = Column(Integer, nullable=False) # ID сообщения у отправителя
recipient_message_id = Column(Integer, nullable=True) # ID сообщения у получателя
is_read = Column(Boolean, default=False, index=True)
read_at = Column(DateTime(timezone=True), nullable=True)
reply_to_id = Column(Integer, ForeignKey("p2p_messages.id"), nullable=True) # Ответ на сообщение
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
# Связи
sender = relationship("User", foreign_keys=[sender_id], backref="sent_p2p_messages")
recipient = relationship("User", foreign_keys=[recipient_id], backref="received_p2p_messages")
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})>"

263
src/core/p2p_services.py Normal file
View File

@@ -0,0 +1,263 @@
"""Сервисы для работы с P2P сообщениями"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, desc, func
from sqlalchemy.orm import selectinload
from typing import List, Optional, Tuple
from datetime import datetime, timezone
from .models import P2PMessage, User
class P2PMessageService:
"""Сервис для работы с P2P сообщениями"""
@staticmethod
async def send_message(
session: AsyncSession,
sender_id: int,
recipient_id: int,
message_type: str,
sender_message_id: int,
recipient_message_id: Optional[int] = None,
text: Optional[str] = None,
file_id: Optional[str] = None,
reply_to_id: Optional[int] = None
) -> P2PMessage:
"""
Сохранить отправленное P2P сообщение
Args:
session: Сессия БД
sender_id: ID отправителя
recipient_id: ID получателя
message_type: Тип сообщения (text, photo, etc.)
sender_message_id: ID сообщения у отправителя
recipient_message_id: ID сообщения у получателя
text: Текст сообщения
file_id: ID файла
reply_to_id: ID сообщения, на которое отвечают
Returns:
P2PMessage
"""
message = P2PMessage(
sender_id=sender_id,
recipient_id=recipient_id,
message_type=message_type,
text=text,
file_id=file_id,
sender_message_id=sender_message_id,
recipient_message_id=recipient_message_id,
reply_to_id=reply_to_id,
created_at=datetime.now(timezone.utc)
)
session.add(message)
await session.commit()
await session.refresh(message)
return message
@staticmethod
async def mark_as_read(session: AsyncSession, message_id: int) -> bool:
"""
Отметить сообщение как прочитанное
Args:
session: Сессия БД
message_id: ID сообщения
Returns:
bool: True если успешно
"""
result = await session.execute(
select(P2PMessage).where(P2PMessage.id == message_id)
)
message = result.scalar_one_or_none()
if message and not message.is_read:
message.is_read = True
message.read_at = datetime.now(timezone.utc)
await session.commit()
return True
return False
@staticmethod
async def get_conversation(
session: AsyncSession,
user1_id: int,
user2_id: int,
limit: int = 50,
offset: int = 0
) -> List[P2PMessage]:
"""
Получить переписку между двумя пользователями
Args:
session: Сессия БД
user1_id: ID первого пользователя
user2_id: ID второго пользователя
limit: Максимальное количество сообщений
offset: Смещение для пагинации
Returns:
List[P2PMessage]: Список сообщений (от новых к старым)
"""
result = await session.execute(
select(P2PMessage)
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
.where(
or_(
and_(P2PMessage.sender_id == user1_id, P2PMessage.recipient_id == user2_id),
and_(P2PMessage.sender_id == user2_id, P2PMessage.recipient_id == user1_id)
)
)
.order_by(desc(P2PMessage.created_at))
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
@staticmethod
async def get_unread_count(session: AsyncSession, user_id: int) -> int:
"""
Получить количество непрочитанных сообщений пользователя
Args:
session: Сессия БД
user_id: ID пользователя
Returns:
int: Количество непрочитанных сообщений
"""
result = await session.execute(
select(func.count(P2PMessage.id))
.where(
and_(
P2PMessage.recipient_id == user_id,
P2PMessage.is_read == False
)
)
)
return result.scalar() or 0
@staticmethod
async def get_recent_conversations(
session: AsyncSession,
user_id: int,
limit: int = 10
) -> List[Tuple[User, P2PMessage, int]]:
"""
Получить список последних диалогов пользователя
Args:
session: Сессия БД
user_id: ID пользователя
limit: Максимальное количество диалогов
Returns:
List[Tuple[User, P2PMessage, int]]: Список (собеседник, последнее_сообщение, непрочитанных)
"""
# Получаем все ID собеседников
result = await session.execute(
select(P2PMessage.sender_id, P2PMessage.recipient_id)
.where(
or_(
P2PMessage.sender_id == user_id,
P2PMessage.recipient_id == user_id
)
)
)
# Собираем уникальных собеседников
peers = set()
for sender_id, recipient_id in result.all():
peer_id = recipient_id if sender_id == user_id else sender_id
peers.add(peer_id)
# Для каждого собеседника получаем последнее сообщение и количество непрочитанных
conversations = []
for peer_id in peers:
# Последнее сообщение
last_msg_result = await session.execute(
select(P2PMessage)
.where(
or_(
and_(P2PMessage.sender_id == user_id, P2PMessage.recipient_id == peer_id),
and_(P2PMessage.sender_id == peer_id, P2PMessage.recipient_id == user_id)
)
)
.order_by(desc(P2PMessage.created_at))
.limit(1)
)
last_message = last_msg_result.scalar_one_or_none()
if not last_message:
continue
# Количество непрочитанных от этого собеседника
unread_result = await session.execute(
select(func.count(P2PMessage.id))
.where(
and_(
P2PMessage.sender_id == peer_id,
P2PMessage.recipient_id == user_id,
P2PMessage.is_read == False
)
)
)
unread_count = unread_result.scalar() or 0
# Получаем пользователя-собеседника
peer_result = await session.execute(
select(User).where(User.id == peer_id)
)
peer = peer_result.scalar_one_or_none()
if peer:
conversations.append((peer, last_message, unread_count))
# Сортируем по времени последнего сообщения
conversations.sort(key=lambda x: x[1].created_at, reverse=True)
return conversations[:limit]
@staticmethod
async def find_original_message(
session: AsyncSession,
telegram_message_id: int,
user_id: int
) -> Optional[P2PMessage]:
"""
Найти оригинальное P2P сообщение по telegram_message_id
Args:
session: Сессия БД
telegram_message_id: ID сообщения в Telegram
user_id: ID пользователя (для проверки прав)
Returns:
Optional[P2PMessage]: Найденное сообщение или None
"""
result = await session.execute(
select(P2PMessage)
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
.where(
or_(
and_(
P2PMessage.sender_message_id == telegram_message_id,
P2PMessage.sender_id == user_id
),
and_(
P2PMessage.recipient_message_id == telegram_message_id,
P2PMessage.recipient_id == user_id
)
)
)
)
return result.scalar_one_or_none()

View File

@@ -49,6 +49,12 @@ class UserService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""Получить пользователя по ID"""
result = await session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
"""Получить пользователя по username"""
@@ -147,6 +153,23 @@ class UserService:
formatted_number = format_account_number(account_number)
if not formatted_number:
return None
@staticmethod
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
"""
Получить пользователя по номеру клубной карты
Args:
session: Сессия БД
club_card_number: Номер клубной карты (4 цифры)
Returns:
User или None если не найден
"""
result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
return result.scalar_one_or_none()
result = await session.execute(
select(User).where(User.account_number == formatted_number)
@@ -210,6 +233,25 @@ class LotteryService:
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def update_lottery(
session: AsyncSession,
lottery_id: int,
**updates
) -> bool:
"""Обновить данные розыгрыша"""
try:
await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.values(**updates)
)
await session.commit()
return True
except Exception:
await session.rollback()
return False
@staticmethod
async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
@@ -247,10 +289,16 @@ class LotteryService:
@staticmethod
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
"""Провести розыгрыш с учетом ручных победителей"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery or lottery.is_completed:
logger.warning(f"conduct_draw: lottery не найден или завершён")
return {}
logger.info(f"conduct_draw: получаем участников")
# Получаем всех участников (включая тех, у кого нет user)
participants = []
for p in lottery.participations:
@@ -265,7 +313,9 @@ class LotteryService:
'account_number': p.account_number
})())
logger.info(f"conduct_draw: участников {len(participants)}")
if not participants:
logger.warning(f"conduct_draw: нет участников")
return {}
# Определяем количество призовых мест
@@ -319,6 +369,7 @@ class LotteryService:
session.add(winner)
# Обновляем статус розыгрыша
logger.info(f"conduct_draw: обновляем статус lottery")
lottery.is_completed = True
lottery.draw_results = {}
for place, info in results.items():
@@ -332,7 +383,8 @@ class LotteryService:
'is_manual': info['is_manual']
}
await session.commit()
# НЕ коммитим здесь - это должно сделать вызывающая функция
logger.info(f"conduct_draw: изменения подготовлены, победителей: {len(results)}")
return results
@staticmethod
@@ -539,6 +591,9 @@ class ParticipationService:
@staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое добавление участников по номерам счетов"""
import logging
logger = logging.getLogger(__name__)
results = {
"added": 0,
"skipped": 0,
@@ -547,35 +602,97 @@ class ParticipationService:
"invalid_accounts": []
}
for account_number in account_numbers:
account_number = account_number.strip()
if not account_number:
for account_input in account_numbers:
account_input = account_input.strip()
if not account_input:
continue
logger.info(f"DEBUG: Processing account_input={account_input!r}")
try:
# Валидируем и форматируем номер
formatted_account = format_account_number(account_number)
if not formatted_account:
results["invalid_accounts"].append(account_number)
results["errors"].append(f"Неверный формат: {account_number}")
continue
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split()
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
# Ищем пользователя по номеру счёта
user = await UserService.get_user_by_account(session, formatted_account)
if not user:
results["errors"].append(f"Пользователь с счётом {formatted_account} не найден")
continue
# Пробуем добавить в розыгрыш
if await ParticipationService.add_participant(session, lottery_id, user.id):
results["added"] += 1
results["details"].append(f"Добавлен: {user.first_name} ({formatted_account})")
if len(parts) == 2:
card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета
card_number = None
account_number = parts[0]
logger.info(f"DEBUG: 1 part - account={account_number!r}")
else:
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}")
continue
# Валидируем и форматируем номер счета
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
formatted_account = format_account_number(account_number)
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
logger.error(f"DEBUG: Format failed for {account_number!r}")
continue
# Ищем владельца счёта через таблицу Account
from ..core.registration_services import AccountService
user = await AccountService.get_account_owner(session, formatted_account)
if not user:
card_info = f" (карта: {card_number})" if card_number else ""
results["errors"].append(f"Пользователь с счётом {formatted_account}{card_info} не найден")
continue
# Получаем запись Account для этого счета
account_record = await session.execute(
select(Account).where(Account.account_number == formatted_account)
)
account_record = account_record.scalar_one_or_none()
if not account_record:
card_info = f" (карта: {card_number})" if card_number else ""
results["errors"].append(f"Запись счета {formatted_account}{card_info} не найдена в базе")
continue
# Проверяем, не участвует ли уже этот счет
existing = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == formatted_account
)
)
if existing.scalar_one_or_none():
results["skipped"] += 1
results["details"].append(f"Уже участвует: {user.first_name} ({formatted_account})")
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(f"Уже участвует: {detail}")
continue
# Добавляем участие по счету
participation = Participation(
lottery_id=lottery_id,
user_id=user.id,
account_id=account_record.id,
account_number=formatted_account
)
session.add(participation)
await session.commit()
results["added"] += 1
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(detail)
except Exception as e:
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
return results
@@ -590,36 +707,70 @@ class ParticipationService:
"invalid_accounts": []
}
for account_number in account_numbers:
account_number = account_number.strip()
if not account_number:
for account_input in account_numbers:
account_input = account_input.strip()
if not account_input:
continue
try:
# Валидируем и форматируем номер
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split()
if len(parts) == 2:
card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета
elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета
card_number = None
account_number = parts[0]
else:
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}")
continue
# Валидируем и форматируем номер счета
formatted_account = format_account_number(account_number)
if not formatted_account:
results["invalid_accounts"].append(account_number)
results["errors"].append(f"Неверный формат: {account_number}")
card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
continue
# Ищем пользователя по номеру счёта
user = await UserService.get_user_by_account(session, formatted_account)
# Ищем владельца счёта через таблицу Account
from ..core.registration_services import AccountService
user = await AccountService.get_account_owner(session, formatted_account)
if not user:
card_info = f" (карта: {card_number})" if card_number else ""
results["not_found"] += 1
results["details"].append(f"Не найден: {formatted_account}")
results["details"].append(f"Не найден: {formatted_account}{card_info}")
continue
# Пробуем удалить из розыгрыша
if await ParticipationService.remove_participant(session, lottery_id, user.id):
# Ищем участие по номеру счета (не по user_id!)
participation = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == formatted_account
)
)
participation = participation.scalar_one_or_none()
if participation:
await session.delete(participation)
await session.commit()
results["removed"] += 1
results["details"].append(f"Удалён: {user.first_name} ({formatted_account})")
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(detail)
else:
results["not_found"] += 1
results["details"].append(f"Не участвовал: {user.first_name} ({formatted_account})")
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(f"Не участвовал: {detail}")
except Exception as e:
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
return results

View File

@@ -42,6 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
"""
Обнаружение ввода счетов в сообщении
Активируется только для администраторов
Извлекает номер клубной карты и определяет владельца
"""
if not is_admin(message.from_user.id):
return
@@ -52,16 +53,75 @@ async def detect_account_input(message: Message, state: FSMContext):
if not accounts:
return # Счета не обнаружены, пропускаем
# Сохраняем счета в состоянии
await state.update_data(detected_accounts=accounts)
# Извлекаем номера клубных карт и определяем владельцев
from ..core.services import UserService
from ..core.registration_services import AccountService
# Формируем сообщение
accounts_text = "\n".join([f"{acc}" for acc in accounts])
async with async_session_maker() as session:
accounts_with_owners = []
for account in accounts:
# Парсим строку счета: может быть "КАРТА СЧЕТ" или просто "СЧЕТ"
parts = account.split()
club_card = None
account_number = None
if len(parts) == 2:
# Формат: "КАРТА СЧЕТ" (например "2521 21-04-80-64-68-25-68")
club_card = parts[0]
account_number = parts[1]
elif len(parts) == 1:
# Формат: только "СЧЕТ" (например "21-04-80-64-68-25-68")
account_number = parts[0]
# Если есть номер клубной карты, ищем владельца
user = None
owner_info = None
if club_card:
user = await UserService.get_user_by_club_card(session, club_card)
if user:
owner_info = f"@{user.username}" if user.username else user.first_name
accounts_with_owners.append({
'account': account,
'club_card': club_card,
'owner': owner_info,
'user_id': user.id if user else None
})
# Сохраняем счета в состоянии
await state.update_data(
detected_accounts=accounts,
accounts_with_owners=accounts_with_owners
)
# Формируем сообщение с владельцами
accounts_text_parts = []
for item in accounts_with_owners:
account = item['account']
club_card = item['club_card']
owner = item['owner']
if owner:
line = f"{account}{owner} (карта: {club_card})"
elif club_card:
line = f"{account} (карта: {club_card}, владелец не найден)"
else:
line = f"{account} (неверный формат)"
accounts_text_parts.append(line)
accounts_text = "\n".join(accounts_text_parts)
count = len(accounts)
# Подсчёт найденных владельцев
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
text = (
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
f"Найдено: <b>{count}</b>\n\n"
f"Найдено: <b>{count}</b>\n"
f"Владельцев определено: <b>{owners_found}</b>\n\n"
f"{accounts_text}\n\n"
f"Выберите действие:"
)

View File

@@ -18,17 +18,34 @@ class AccountParticipationService:
account_number: str
) -> Dict[str, Any]:
"""
Добавить счет в розыгрыш
Добавить счет в розыгрыш.
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
Returns:
Dict с ключами: success, message, account_number
"""
# Валидируем и форматируем
formatted_account = format_account_number(account_number)
if not formatted_account:
# Разделяем по пробелу если есть номер карты
parts = account_number.split()
if len(parts) == 2:
card_number = parts[0]
account_to_format = parts[1]
elif len(parts) == 1:
card_number = None
account_to_format = parts[0]
else:
return {
"success": False,
"message": f"Неверный формат счета: {account_number}",
"message": f"Неверный формат: {account_number}",
"account_number": account_number
}
# Валидируем и форматируем только часть счета
formatted_account = format_account_number(account_to_format)
if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": False,
"message": f"Неверный формат счета: {account_to_format}{card_info}",
"account_number": account_number
}
@@ -49,24 +66,37 @@ class AccountParticipationService:
)
)
if existing.scalar_one_or_none():
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": False,
"message": f"Счет {formatted_account} уже участвует в розыгрыше",
"message": f"Счет {formatted_account}{card_info} уже участвует в розыгрыше",
"account_number": formatted_account
}
# Добавляем участие
# Получаем запись Account и владельца
from ..core.registration_services import AccountService
from ..core.models import Account
user = await AccountService.get_account_owner(session, formatted_account)
account_record = await session.execute(
select(Account).where(Account.account_number == formatted_account)
)
account_record = account_record.scalar_one_or_none()
# Добавляем участие с полными данными
participation = Participation(
lottery_id=lottery_id,
account_number=formatted_account,
user_id=None # Без привязки к пользователю
user_id=user.id if user else None,
account_id=account_record.id if account_record else None
)
session.add(participation)
await session.commit()
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": True,
"message": f"Счет {formatted_account} добавлен в розыгрыш",
"message": f"Счет {formatted_account}{card_info} добавлен в розыгрыш",
"account_number": formatted_account
}
@@ -199,30 +229,52 @@ class AccountParticipationService:
session: AsyncSession,
lottery_id: int,
account_number: str,
place: int,
prize: Optional[str] = None
) -> Dict[str, Any]:
place: int = 1,
prize: str = ""
):
"""
Установить счет как победителя на указанное место
Устанавливает счет как победителя в розыгрыше.
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
"""
formatted_account = format_account_number(account_number)
# Разделяем номер карты и счета, если они указаны вместе
card_number = None
parts = account_number.split()
if len(parts) == 2:
# Формат: "КАРТА СЧЕТ"
card_number = parts[0]
account_to_format = parts[1]
elif len(parts) == 1:
# Формат: только "СЧЕТ"
account_to_format = parts[0]
else:
return {
"success": False,
"message": f"❌ Неверный формат: {account_number}"
}
# Форматируем номер счета
formatted_account = format_account_number(account_to_format)
if not formatted_account:
return {
"success": False,
"message": f"Неверный формат счета: {account_number}"
"message": f"Неверный формат счета: {account_number}"
}
# Проверяем, участвует ли счет в розыгрыше
# Проверяем, что счет участвует в розыгрыше
participation = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == formatted_account
)
)
if not participation.scalar_one_or_none():
participation = participation.scalar_one_or_none()
if not participation:
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": False,
"message": f"Счет {formatted_account} не участвует в розыгрыше"
"message": f"Счет {formatted_account}{card_info} не участвует в розыгрыше"
}
# Проверяем, не занято ли уже это место
@@ -255,9 +307,10 @@ class AccountParticipationService:
await session.commit()
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": True,
"message": f"Счет {formatted_account} установлен победителем на место {place}",
"message": f"Счет {formatted_account}{card_info} установлен победителем на место {place}",
"account_number": formatted_account,
"place": place
}

View File

@@ -22,6 +22,14 @@ class AddAccountStates(StatesGroup):
choosing_lottery = State()
@router.message(Command("cancel"))
@admin_only
async def cancel_command(message: Message, state: FSMContext):
"""Отменить текущую операцию и сбросить состояние"""
await state.clear()
await message.answer("✅ Состояние сброшено. Все операции отменены.")
@router.message(Command("add_account"))
@admin_only
async def add_account_command(message: Message, state: FSMContext):
@@ -43,11 +51,12 @@ async def add_account_command(message: Message, state: FSMContext):
await state.set_state(AddAccountStates.waiting_for_data)
await message.answer(
"💳 **Добавление счетов**\n\n"
"Отправьте данные в формате:\n"
"лубная_карта номер_счета`\n\n"
"**Для одного счета:**\n"
"`2223 11-22-33-44-55-66-77`\n\n"
"**Для нескольких счетов (каждый с новой строки):**\n"
"📋 **Формат 1 (однострочный):**\n"
"арта счет`\n"
"Пример: `2223 11-22-33-44-55-66-77`\n\n"
"📋 **Формат 2 (многострочный из таблицы):**\n"
"Скопируйте столбцы со счетами и картами - система сама распознает\n\n"
"**Для нескольких счетов:**\n"
"`2223 11-22-33-44-55-66-77`\n"
"`2223 88-99-00-11-22-33-44`\n"
"`3334 12-34-56-78-90-12-34`\n\n"
@@ -86,13 +95,14 @@ async def process_single_account(message: Message, club_card: str, account_numbe
if owner:
text += f"👤 Владелец: {owner.first_name}\n\n"
# Отправляем уведомление владельцу
# Отправляем уведомление владельцу с форматированием
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах с этим счетом!"
f"💳 `{account_number}`\n\n"
f"Теперь вы можете участвовать в розыгрышах!",
parse_mode="Markdown"
)
text += "📨 Владельцу отправлено уведомление\n\n"
except Exception as e:
@@ -118,17 +128,66 @@ async def process_accounts_data(message: Message, state: FSMContext):
return
lines = message.text.strip().split('\n')
# Ограничение: максимум 1000 счетов за раз
MAX_ACCOUNTS = 1000
if len(lines) > MAX_ACCOUNTS:
await message.answer(
f"⚠️ Слишком много счетов!\n\n"
f"Максимум за раз: {MAX_ACCOUNTS}\n"
f"Вы отправили: {len(lines)} строк\n\n"
f"Разделите данные на несколько частей."
)
await state.clear()
return
# Отправляем начальное уведомление
progress_msg = await message.answer(
f"⏳ Обработка {len(lines)} строк...\n"
f"Пожалуйста, подождите..."
)
accounts_data = []
errors = []
for i, line in enumerate(lines, 1):
parts = line.strip().split()
if len(parts) != 2:
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
# Универсальный парсер: поддержка однострочного и многострочного формата
i = 0
while i < len(lines):
line = lines[i].strip()
# Пропускаем пустые строки и строки с названиями/датами
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
i += 1
continue
club_card, account_number = parts
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
if ' ' in line:
# Однострочный формат: разделяем по первому пробелу
parts = line.split(maxsplit=1)
if len(parts) == 2:
club_card, account_number = parts
else:
errors.append(f"Строка {i+1}: неверный формат")
i += 1
continue
else:
# Многострочный формат: текущая строка - счет, следующая - карта
account_number = line
i += 1
if i >= len(lines):
errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
break
club_card = lines[i].strip()
# Пропускаем, если следующая строка содержит мусор
if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
i += 1
continue
# Создаем счет
try:
async with async_session_maker() as session:
account = await AccountService.create_account(
@@ -143,25 +202,99 @@ async def process_accounts_data(message: Message, state: FSMContext):
'club_card': club_card,
'account_number': account_number,
'account_id': account.id,
'owner': owner
'owner': owner,
'owner_id': owner.telegram_id if owner else None
})
# Отправляем уведомление владельцу
if owner:
# Обновляем progress каждые 50 счетов
if len(accounts_data) % 50 == 0:
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах!"
await progress_msg.edit_text(
f"⏳ Обработано: {len(accounts_data)} / ~{len(lines)}\n"
f"❌ Ошибок: {len(errors)}"
)
except:
pass
pass # Игнорируем ошибки редактирования
except ValueError as e:
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
except Exception as e:
errors.append(f"Строка {i}: {str(e)}")
errors.append(f"Счет {account_number}: {str(e)}")
i += 1
# Удаляем progress сообщение
try:
await progress_msg.delete()
except:
pass
# Группируем счета по владельцам и отправляем групповые уведомления
if accounts_data:
from collections import defaultdict
accounts_by_owner = defaultdict(list)
for acc in accounts_data:
if acc['owner_id']:
accounts_by_owner[acc['owner_id']].append(acc['account_number'])
# Отправляем групповые уведомления
for owner_id, account_numbers in accounts_by_owner.items():
try:
if len(account_numbers) == 1:
# Одиночное уведомление
notification_text = (
"К вашему профилю добавлен счет:\n\n"
f"💳 `{account_numbers[0]}`\n\n"
"Теперь вы можете участвовать в розыгрышах!"
)
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
elif len(account_numbers) <= 50:
# Групповое уведомление (до 50 счетов)
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Ваши счета:*\n"
)
for acc_num in account_numbers:
notification_text += f"• `{acc_num}`\n"
notification_text += "\nТеперь вы можете участвовать в розыгрышах!"
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
else:
# Много счетов - показываем первые 10 и кнопку
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Первые 10 счетов:*\n"
)
for acc_num in account_numbers[:10]:
notification_text += f"• `{acc_num}`\n"
notification_text += f"\n_...и ещё {len(account_numbers) - 10} счетов_\n\n"
notification_text += "Теперь вы можете участвовать в розыгрышах!"
# Кнопка для просмотра всех счетов
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="📋 Просмотреть все счета",
callback_data="view_my_accounts"
)]
])
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown",
reply_markup=keyboard
)
except Exception as e:
pass # Игнорируем ошибки отправки уведомлений
# Формируем отчет
text = f"📊 **Результаты добавления счетов**\n\n"
@@ -305,31 +438,70 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
@admin_only
async def remove_account_command(message: Message):
"""
Деактивировать счет
Формат: /remove_account <account_number>
Деактивировать счет(а)
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
Можно указать несколько счетов через пробел для массового удаления
"""
parts = message.text.split()
if len(parts) != 2:
if len(parts) < 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /remove_account <account_number>"
"Используйте: /remove_account <account_number1> [account_number2] ...\n\n"
"Примеры:\n"
"• /remove_account 12-34-56-78-90-12-34\n"
"• /remove_account 12-34-56-78-90-12-34 98-76-54-32-10-98-76"
)
return
account_number = parts[1]
account_numbers = parts[1:] # Все аргументы после команды
try:
async with async_session_maker() as session:
success = await AccountService.deactivate_account(session, account_number)
results = {
'success': [],
'not_found': [],
'errors': []
}
if success:
await message.answer(f"✅ Счет {account_number} деактивирован")
async with async_session_maker() as session:
for account_number in account_numbers:
try:
success = await AccountService.deactivate_account(session, account_number)
if success:
results['success'].append(account_number)
else:
results['not_found'].append(account_number)
except Exception as e:
results['errors'].append((account_number, str(e)))
# Формируем отчёт
response_parts = []
if results['success']:
response_parts.append(
f"✅ *Деактивировано счетов: {len(results['success'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['success'])
)
if results['not_found']:
response_parts.append(
f"❌ *Не найдено счетов: {len(results['not_found'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['not_found'])
)
if results['errors']:
response_parts.append(
f"⚠️ *Ошибки при обработке: {len(results['errors'])}*\n"
+ "\n".join(f"• `{acc}`: {err}" for acc, err in results['errors'])
)
if not response_parts:
await message.answer("Не удалось обработать ни один счет")
else:
await message.answer(f"❌ Счет {account_number} не найден")
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
except Exception as e:
await message.answer(f"Ошибка: {str(e)}")
await message.answer(f"Критическая ошибка: {str(e)}")
@router.message(Command("verify_winner"))
@@ -569,3 +741,71 @@ async def user_info_command(message: Message):
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data == "view_my_accounts")
async def view_my_accounts_callback(callback: CallbackQuery):
"""Показать все счета пользователя"""
import asyncio
try:
async with async_session_maker() as session:
# Получаем пользователя
user_result = await session.execute(
select(User).where(User.telegram_id == callback.from_user.id)
)
user = user_result.scalar_one_or_none()
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Получаем все счета
accounts = await AccountService.get_user_accounts(session, user.id)
if not accounts:
await callback.answer("У вас нет счетов", show_alert=True)
return
# Отвечаем на callback сразу, чтобы не было timeout
await callback.answer("⏳ Загружаю ваши счета...")
# Если счетов много - предупреждаем о задержке
batches_count = (len(accounts) + 49) // 50 # Округление вверх
if batches_count > 5:
await callback.message.answer(
f"📊 Найдено счетов: *{len(accounts)}*\n"
f"📤 Отправка {batches_count} сообщений с задержкой (~{batches_count//2} сек)\n\n"
f"⏳ _Пожалуйста, подождите. Бот не завис._",
parse_mode="Markdown"
)
# Формируем сообщение с пагинацией (по 50 счетов на сообщение)
BATCH_SIZE = 50
for i in range(0, len(accounts), BATCH_SIZE):
batch = accounts[i:i+BATCH_SIZE]
text = f"💳 *Ваши счета ({i+1}-{min(i+BATCH_SIZE, len(accounts))} из {len(accounts)}):*\n\n"
for acc in batch:
status = "" if acc.is_active else ""
text += f"{status} `{acc.account_number}`\n"
try:
await callback.message.answer(text, parse_mode="Markdown")
# Задержка между сообщениями для избежания flood control
if i + BATCH_SIZE < len(accounts):
await asyncio.sleep(0.5) # 500ms между сообщениями
except Exception as send_error:
# Если flood control - ждём дольше
if "Flood control" in str(send_error) or "Too Many Requests" in str(send_error):
await asyncio.sleep(2)
await callback.message.answer(text, parse_mode="Markdown")
else:
raise
except Exception as e:
# Не используем callback.answer в except - может быть timeout
try:
await callback.message.answer(f"❌ Ошибка: {str(e)}")
except:
pass # Игнорируем если не получилось отправить

View File

@@ -1,10 +1,12 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
import logging
from aiogram import Router, F
from aiogram.types import (
CallbackQuery, Message, InlineKeyboardButton, InlineKeyboardMarkup
)
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -14,8 +16,37 @@ import json
from ..core.database import async_session_maker
from ..core.services import UserService, LotteryService, ParticipationService
from ..core.chat_services import ChatMessageService
from ..core.config import ADMIN_IDS
from ..core.models import User, Lottery, Participation, Account
from ..core.models import User, Lottery, Participation, Account, ChatMessage
logger = logging.getLogger(__name__)
async def safe_edit_message(
callback: CallbackQuery,
text: str,
reply_markup: InlineKeyboardMarkup | None = None,
parse_mode: str = "Markdown"
) -> bool:
"""
Безопасное редактирование сообщения с обработкой ошибки 'message is not modified'
Returns:
bool: True если сообщение отредактировано, False если не изменилось
"""
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
return True
except TelegramBadRequest as e:
if "message is not modified" in str(e):
await callback.answer("Сообщение уже актуально", show_alert=False)
return False
raise
# Состояния для админки
@@ -174,30 +205,73 @@ async def show_lottery_management(callback: CallbackQuery):
@admin_router.callback_query(F.data == "admin_create_lottery")
async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
"""Начать создание розыгрыша"""
logging.info(f"🎯 Callback admin_create_lottery получен от пользователя {callback.from_user.id}")
# Сразу отвечаем на callback
await callback.answer()
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
logging.warning(f"⚠️ Пользователь {callback.from_user.id} не является админом")
await callback.message.answer("❌ Недостаточно прав")
return
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
text = "📝 Создание нового розыгрыша\n\n"
text += "Шаг 1 из 4\n\n"
text += "Введите название розыгрыша:"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await state.set_state(AdminStates.lottery_title)
try:
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await state.set_state(AdminStates.lottery_title)
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
except Exception as e:
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
await callback.message.answer(f"❌ Ошибка: {str(e)}")
@admin_router.message(StateFilter(AdminStates.lottery_title))
async def process_lottery_title(message: Message, state: FSMContext):
"""Обработка названия розыгрыша"""
"""Обработка названия розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
title=message.text
)
if success:
await message.answer(f"✅ Название изменено на: {message.text}")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении названия")
return
# Если это создание нового розыгрыша
await state.update_data(title=message.text)
text = f"📝 Создание нового розыгрыша\n\n"
@@ -211,11 +285,42 @@ async def process_lottery_title(message: Message, state: FSMContext):
@admin_router.message(StateFilter(AdminStates.lottery_description))
async def process_lottery_description(message: Message, state: FSMContext):
"""Обработка описания розыгрыша"""
"""Обработка описания розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
description = None if message.text == "-" else message.text
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
description=description
)
if success:
await message.answer(f"✅ Описание изменено")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении описания")
return
# Если это создание нового розыгрыша
description = None if message.text == "-" else message.text
await state.update_data(description=description)
@@ -238,12 +343,43 @@ async def process_lottery_description(message: Message, state: FSMContext):
@admin_router.message(StateFilter(AdminStates.lottery_prizes))
async def process_lottery_prizes(message: Message, state: FSMContext):
"""Обработка призов розыгрыша"""
"""Обработка призов розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()]
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
prizes=prizes
)
if success:
await message.answer(f"✅ Призы изменены")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении призов")
return
# Если это создание нового розыгрыша
await state.update_data(prizes=prizes)
data = await state.get_data()
@@ -406,7 +542,14 @@ async def show_lottery_detail(callback: CallbackQuery):
text += f"🏆 Результаты:\n"
for winner in winners:
manual_mark = " 👑" if winner.is_manual else ""
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
# Безопасная обработка победителя - может быть без user_id
if winner.user:
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
# Победитель по номеру счета без связанного пользователя
username = f"Счет: {winner.account_number}"
text += f"{winner.place}. {username}{manual_mark}\n"
buttons = []
@@ -1344,13 +1487,9 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
data = await state.get_data()
lottery_id = data['bulk_add_accounts_lottery_id']
# Парсим входные данные - поддерживаем и запятые, и переносы строк
account_inputs = []
for line in message.text.split('\n'):
for account in line.split(','):
account = account.strip()
if account:
account_inputs.append(account)
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
async with async_session_maker() as session:
# Массовое добавление по номерам счетов
@@ -1473,13 +1612,9 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
data = await state.get_data()
lottery_id = data['bulk_remove_accounts_lottery_id']
# Парсим входные данные - поддерживаем и запятые, и переносы строк
account_inputs = []
for line in message.text.split('\n'):
for account in line.split(','):
account = account.strip()
if account:
account_inputs.append(account)
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
async with async_session_maker() as session:
# Массовое удаление по номерам счетов
@@ -1676,6 +1811,47 @@ async def start_edit_lottery(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_edit_field_"))
async def handle_edit_field(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора поля для редактирования"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
# Парсим callback_data: admin_edit_field_{lottery_id}_{field_name}
parts = callback.data.split("_")
if len(parts) < 5:
await callback.answer("❌ Неверный формат данных", show_alert=True)
return
lottery_id = int(parts[3]) # admin_edit_field_{lottery_id}_...
field_name = "_".join(parts[4:]) # Всё после lottery_id это имя поля
await state.update_data(edit_lottery_id=lottery_id, edit_field=field_name)
# Определяем, что редактируем
if field_name == "title":
text = "📝 Введите новое название розыгрыша:"
await state.set_state(AdminStates.lottery_title)
elif field_name == "description":
text = "📄 Введите новое описание розыгрыша:"
await state.set_state(AdminStates.lottery_description)
elif field_name == "prizes":
text = "🎁 Введите новый список призов (каждый приз с новой строки):"
await state.set_state(AdminStates.lottery_prizes)
else:
await callback.answer("❌ Неизвестное поле", show_alert=True)
return
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await callback.answer()
@admin_router.callback_query(F.data.startswith("admin_edit_"))
async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
"""Редирект на редактирование розыгрыша из детального просмотра"""
@@ -1734,7 +1910,7 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
async def toggle_lottery_active(callback: CallbackQuery):
async def toggle_lottery_active(callback: CallbackQuery, state: FSMContext):
"""Переключить активность розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
@@ -1756,7 +1932,7 @@ async def toggle_lottery_active(callback: CallbackQuery):
await callback.answer("❌ Ошибка изменения статуса", show_alert=True)
# Обновляем отображение
await choose_edit_field(callback, None)
await choose_edit_field(callback, state)
@admin_router.callback_query(F.data == "admin_finish_lottery")
@@ -2590,9 +2766,9 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша"""
@admin_router.callback_query(F.data.regexp(r"^admin_conduct_\d+$"))
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
"""Запрос подтверждения проведения розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -2616,10 +2792,94 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Подсчёт призов
prizes_count = len(lottery.prizes) if lottery.prizes else 0
# Формируем сообщение с подтверждением
text = f"⚠️ *Подтверждение проведения розыгрыша*\n\n"
text += f"🎲 *Розыгрыш:* {lottery.title}\n"
text += f"👥 *Участников:* {participants_count}\n"
text += f"🏆 *Призов:* {prizes_count}\n\n"
if lottery.prizes:
text += "*Призы:*\n"
for i, prize in enumerate(lottery.prizes, 1):
text += f"{i}. {prize}\n"
text += "\n"
text += "❗️ *Внимание:* После проведения розыгрыша результаты нельзя будет изменить!\n\n"
text += "Продолжить?"
confirm_callback = f"admin_conduct_confirmed_{lottery_id}"
logger.info(f"Создаём кнопку подтверждения с callback_data='{confirm_callback}'")
buttons = [
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=confirm_callback)],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
]
await safe_edit_message(callback, text, InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша после подтверждения"""
logger.info(f"🎯 conduct_lottery_draw HANDLER TRIGGERED! data={callback.data}, user={callback.from_user.id}")
logger.info(f"conduct_lottery_draw вызван: callback.data={callback.data}, user_id={callback.from_user.id}")
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
logger.info(f"Извлечен lottery_id={lottery_id}")
async with async_session_maker() as session:
logger.info(f"Создана сессия БД")
lottery = await LotteryService.get_lottery(session, lottery_id)
logger.info(f"Получен lottery: {lottery.title if lottery else None}, is_completed={lottery.is_completed if lottery else None}")
if not lottery:
await callback.answer("Розыгрыш не найден", show_alert=True)
return
if lottery.is_completed:
await callback.answer("Розыгрыш уже завершён", show_alert=True)
return
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
if participants_count == 0:
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Показываем индикатор загрузки
await callback.answer("⏳ Проводится розыгрыш...", show_alert=True)
# Проводим розыгрыш через сервис
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
logger.info(f"Начинаем проведение розыгрыша {lottery_id}")
try:
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
logger.info(f"Розыгрыш {lottery_id} проведён, победителей: {len(winners_dict)}")
except Exception as e:
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
await session.rollback()
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
return
if winners_dict:
# Коммитим изменения в БД
await session.commit()
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
# Отправляем уведомления победителям
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(callback.bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
# Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id)
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
@@ -2633,6 +2893,8 @@ async def conduct_lottery_draw(callback: CallbackQuery):
else:
text += f"{winner.place} место: ID {winner.user_id}\n"
text += "\n✅ Уведомления отправлены победителям"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
@@ -3151,5 +3413,286 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
await state.clear()
# ============= УПРАВЛЕНИЕ СООБЩЕНИЯМИ ПОЛЬЗОВАТЕЛЕЙ =============
@admin_router.callback_query(F.data == "admin_messages")
async def show_messages_menu(callback: CallbackQuery):
"""Показать меню управления сообщениями"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
text = "💬 *Управление сообщениями пользователей*\n\n"
text += "Здесь вы можете просматривать и удалять сообщения пользователей.\n\n"
text += "Выберите действие:"
buttons = [
[InlineKeyboardButton(text="📋 Последние сообщения", callback_data="admin_messages_recent")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data == "admin_messages_recent")
async def show_recent_messages(callback: CallbackQuery, page: int = 0):
"""Показать последние сообщения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
limit = 10
offset = page * limit
async with async_session_maker() as session:
messages = await ChatMessageService.get_user_messages_all(
session,
limit=limit,
offset=offset,
include_deleted=False
)
if not messages:
text = "💬 Нет сообщений для отображения"
buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages")]]
else:
text = f"💬 *Последние сообщения*\n\n"
# Добавляем кнопки для просмотра сообщений
buttons = []
for msg in messages:
sender = msg.sender
username = f"@{sender.username}" if sender.username else f"ID{sender.telegram_id}"
msg_preview = ""
if msg.text:
msg_preview = msg.text[:20] + "..." if len(msg.text) > 20 else msg.text
else:
msg_preview = msg.message_type
buttons.append([InlineKeyboardButton(
text=f"👁 {username}: {msg_preview}",
callback_data=f"admin_message_view_{msg.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data.startswith("admin_message_view_"))
async def view_message(callback: CallbackQuery):
"""Просмотр конкретного сообщения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
message_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
msg = await ChatMessageService.get_message(session, message_id)
if not msg:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
sender = msg.sender
username = f"@{sender.username}" if sender.username else f"ID: {sender.telegram_id}"
text = f"💬 *Просмотр сообщения*\n\n"
text += f"👤 Отправитель: {username}\n"
text += f"🆔 Telegram ID: `{sender.telegram_id}`\n"
text += f"📝 Тип: {msg.message_type}\n"
text += f"📅 Дата: {msg.created_at.strftime('%d.%m.%Y %H:%M:%S')}\n\n"
if msg.text:
text += f"📄 *Текст:*\n{msg.text}\n\n"
if msg.file_id:
text += f"📎 File ID: `{msg.file_id}`\n\n"
if msg.is_deleted:
text += f"🗑 *Удалено:* Да\n"
if msg.deleted_at:
text += f" Дата: {msg.deleted_at.strftime('%d.%m.%Y %H:%M')}\n"
buttons = []
# Кнопка удаления (если еще не удалено)
if not msg.is_deleted:
buttons.append([InlineKeyboardButton(
text="🗑 Удалить сообщение",
callback_data=f"admin_message_delete_{message_id}"
)])
# Кнопка для просмотра всех сообщений пользователя
buttons.append([InlineKeyboardButton(
text="📋 Все сообщения пользователя",
callback_data=f"admin_messages_user_{sender.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 К списку", callback_data="admin_messages_recent")])
# Если сообщение содержит медиа, попробуем его показать
if msg.file_id and msg.message_type in ['photo', 'video', 'document', 'animation']:
try:
if msg.message_type == 'photo':
await callback.message.answer_photo(
photo=msg.file_id,
caption=text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
await callback.message.delete()
await callback.answer()
return
elif msg.message_type == 'video':
await callback.message.answer_video(
video=msg.file_id,
caption=text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
await callback.message.delete()
await callback.answer()
return
except Exception as e:
logger.error(f"Ошибка при отправке медиа: {e}")
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data.startswith("admin_message_delete_"))
async def delete_message(callback: CallbackQuery):
"""Удалить сообщение пользователя"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
message_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
msg = await ChatMessageService.get_message(session, message_id)
if not msg:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
# Получаем админа
admin = await UserService.get_or_create_user(
session,
callback.from_user.id,
callback.from_user.username,
callback.from_user.first_name,
callback.from_user.last_name
)
# Помечаем сообщение как удаленное
success = await ChatMessageService.mark_as_deleted(
session,
message_id,
admin.id
)
if success:
# Пытаемся удалить сообщение из чата пользователя
try:
if msg.forwarded_message_ids:
# Удаляем пересланные копии у всех пользователей
for user_tg_id, tg_msg_id in msg.forwarded_message_ids.items():
try:
await callback.bot.delete_message(
chat_id=int(user_tg_id),
message_id=tg_msg_id
)
except Exception as e:
logger.warning(f"Не удалось удалить сообщение {tg_msg_id} у пользователя {user_tg_id}: {e}")
# Удаляем оригинальное сообщение у отправителя
try:
await callback.bot.delete_message(
chat_id=msg.sender.telegram_id,
message_id=msg.telegram_message_id
)
except Exception as e:
logger.warning(f"Не удалось удалить оригинальное сообщение: {e}")
await callback.answer("✅ Сообщение удалено!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка при удалении сообщений: {e}")
await callback.answer("⚠️ Помечено как удаленное", show_alert=True)
else:
await callback.answer("❌ Ошибка при удалении", show_alert=True)
# Возвращаемся к списку
await show_recent_messages(callback, 0)
@admin_router.callback_query(F.data.startswith("admin_messages_user_"))
async def show_user_messages(callback: CallbackQuery):
"""Показать все сообщения конкретного пользователя"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
user_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
user = await UserService.get_user_by_id(session, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
messages = await ChatMessageService.get_user_messages(
session,
user_id,
limit=20,
include_deleted=True
)
username = f"@{user.username}" if user.username else f"ID: {user.telegram_id}"
text = f"💬 *Сообщения {username}*\n\n"
if not messages:
text += "Нет сообщений"
buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages_recent")]]
else:
# Кнопки для просмотра отдельных сообщений
buttons = []
for msg in messages[:15]:
status = "🗑" if msg.is_deleted else ""
msg_preview = ""
if msg.text:
msg_preview = msg.text[:25] + "..." if len(msg.text) > 25 else msg.text
else:
msg_preview = msg.message_type
buttons.append([InlineKeyboardButton(
text=f"{status} {msg_preview} ({msg.created_at.strftime('%d.%m %H:%M')})",
callback_data=f"admin_message_view_{msg.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages_recent")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
# Экспорт роутера
__all__ = ['admin_router']

View File

@@ -105,6 +105,77 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
@router.message(F.text)
async def handle_text_message(message: Message):
"""Обработчик текстовых сообщений"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
if message.reply_to_message and is_admin(message.from_user.id):
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
async with async_session_maker() as session:
# Ищем сообщение в БД по telegram_message_id
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
session,
telegram_message_id=message.reply_to_message.message_id
)
if msg_to_delete:
# Получаем админа
admin = await UserService.get_user_by_telegram_id(
session,
message.from_user.id
)
# Помечаем как удаленное
success = await ChatMessageService.mark_as_deleted(
session,
msg_to_delete.id,
admin.id if admin else None
)
if success:
# Удаляем у всех получателей
deleted_count = 0
if msg_to_delete.forwarded_message_ids:
for user_tg_id, tg_msg_id in msg_to_delete.forwarded_message_ids.items():
try:
await message.bot.delete_message(
chat_id=int(user_tg_id),
message_id=tg_msg_id
)
deleted_count += 1
except Exception as e:
logger.warning(f"Не удалось удалить {tg_msg_id} у {user_tg_id}: {e}")
# Удаляем оригинал у отправителя
try:
await message.bot.delete_message(
chat_id=msg_to_delete.sender.telegram_id,
message_id=msg_to_delete.telegram_message_id
)
deleted_count += 1
except Exception as e:
logger.warning(f"Не удалось удалить оригинал: {e}")
# Удаляем команду админа
try:
await message.delete()
except:
pass
# Отправляем уведомление (самоудаляющееся)
notification = await message.answer(f"✅ Сообщение удалено у {deleted_count} получателей")
await asyncio.sleep(3)
try:
await notification.delete()
except:
pass
return
else:
await message.answer("❌ Сообщение не найдено в БД")
return
# Проверяем является ли это командой
if message.text and message.text.startswith('/'):
# Список команд, которые НЕ нужно пересылать
@@ -119,21 +190,20 @@ async def handle_text_message(message: Message):
# Извлекаем команду (первое слово)
command = message.text.split()[0] if message.text else ''
# Если это пользовательская команда - пропускаем, она будет обработана другими обработчиками
if command in user_commands:
return
# Если это админская команда
if command in admin_commands:
# Проверяем права админа
if not is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды")
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
if is_admin(message.from_user.id):
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
if command in admin_commands:
return
# Если админ - команда будет обработана другими обработчиками, пропускаем пересылку
# Если это пользовательская команда от админа - тоже пропускаем
if command in user_commands:
return
# Любая другая команда от админа - тоже не пересылаем
return
# Если неизвестная команда - тоже не пересылаем
return
# ИЗМЕНЕНИЕ: Если команда от обычного пользователя - ПЕРЕСЫЛАЕМ админу
# Чтобы админ видел, что пользователь отправил /start или другую команду
# НЕ делаем return, продолжаем выполнение для пересылки
async with async_session_maker() as session:
# Проверяем права на отправку
@@ -159,7 +229,8 @@ async def handle_text_message(message: Message):
# Обрабатываем в зависимости от режима
if settings.mode == 'broadcast':
# Режим рассылки с планировщиком
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Сохраняем сообщение в историю
await ChatMessageService.save_message(
@@ -171,11 +242,13 @@ async def handle_text_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(
f"✅ Сообщение разослано!\n"
f"📤 Доставлено: {success}\n"
f"Не доставлено: {fail}"
)
# Показываем статистику доставки только админам
if is_admin(message.from_user.id):
await message.answer(
f"✅ Сообщение разослано!\n"
f"📤 Доставлено: {success}\n"
f"Не доставлено: {fail}"
)
elif settings.mode == 'forward':
# Режим пересылки в канал
@@ -225,7 +298,12 @@ async def handle_photo_message(message: Message):
photo = message.photo[-1]
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# Отправляем только админам
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
admin_only=True
)
await ChatMessageService.save_message(
session,
@@ -237,7 +315,9 @@ async def handle_photo_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Фото разослано: {success} получателей")
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Фото отправлено админам: {success}")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -277,7 +357,8 @@ async def handle_video_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -289,7 +370,9 @@ async def handle_video_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Видео разослано: {success} получателей")
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Видео разослано: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -329,7 +412,8 @@ async def handle_document_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -341,7 +425,9 @@ async def handle_document_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Документ разослан: {success} получателей")
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Документ разослан: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -381,7 +467,8 @@ async def handle_animation_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -393,7 +480,9 @@ async def handle_animation_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Анимация разослана: {success} получателей")
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Анимация разослана: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -433,7 +522,8 @@ async def handle_sticker_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -444,7 +534,9 @@ async def handle_sticker_message(message: Message):
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Стикер разослан: {success} получателей")
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Стикер разослан: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -464,49 +556,19 @@ async def handle_sticker_message(message: Message):
@router.message(F.voice)
async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений"""
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
await message.answer(f"{reason}")
return
settings = await ChatSettingsService.get_or_create_settings(session)
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
user_id=user.id,
telegram_message_id=message.message_id,
message_type='voice',
file_id=message.voice.file_id,
forwarded_ids=forwarded_ids
)
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
if success:
await ChatMessageService.save_message(
session,
user_id=user.id,
telegram_message_id=message.message_id,
message_type='voice',
file_id=message.voice.file_id,
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Голосовое сообщение переслано в канал")
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
await message.answer(
"🚫 Голосовые сообщения запрещены.\n\n"
"Пожалуйста, используйте текстовые сообщения или изображения."
)
return
@router.message(F.audio)
async def handle_audio_message(message: Message):
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
await message.answer(
"🚫 Аудиофайлы запрещены.\n\n"
"Пожалуйста, используйте текстовые сообщения или изображения."
)
return

View File

@@ -0,0 +1,178 @@
"""
Хэндлеры для управления сообщениями администратором
"""
import logging
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from ..core.config import ADMIN_IDS
from ..core.database import async_session_maker
from ..core.chat_services import ChatMessageService
from ..core.services import UserService
logger = logging.getLogger(__name__)
message_admin_router = Router(name="message_admin")
def is_admin(user_id: int) -> bool:
"""Проверка, является ли пользователь администратором"""
return user_id in ADMIN_IDS
@message_admin_router.message(Command("delete"))
async def delete_replied_message(message: Message):
"""
Удаление сообщения по команде /delete
Работает только если команда является ответом на сообщение бота
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
if not message.reply_to_message:
await message.answer("⚠️ Ответьте на сообщение бота командой /delete чтобы удалить его")
return
if message.reply_to_message.from_user.id != message.bot.id:
await message.answer("⚠️ Можно удалять только сообщения бота")
return
try:
# Удаляем сообщение бота
await message.reply_to_message.delete()
# Удаляем команду
await message.delete()
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await message.answer(f"Не удалось удалить сообщение: {str(e)}")
@message_admin_router.callback_query(F.data == "delete_message")
async def delete_message_callback(callback: CallbackQuery):
"""
Удаление сообщения по нажатию кнопки
"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
try:
await callback.message.delete()
await callback.answer("✅ Сообщение удалено")
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
# Функция-фильтр для проверки триггерных слов
def is_delete_trigger(message: Message) -> bool:
"""Проверяет, является ли сообщение триггером для удаления"""
if not message.text:
return False
text_lower = message.text.lower().strip()
triggers = ["удалить", "delete", "del", "🗑️", "🗑", ""]
return any(trigger in text_lower for trigger in triggers)
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
async def quick_delete_replied_message(message: Message):
"""
Быстрое удаление сообщения по reply с триггерными словами или emoji
Работает для админов при ответе на любое сообщение
Триггеры:
- "удалить", "delete", "del"
- 🗑️ (мусорная корзина)
- ❌ (крестик)
Удаляет сообщение у всех получателей broadcast рассылки
"""
if not is_admin(message.from_user.id):
return # Не админ - пропускаем
try:
replied_msg = message.reply_to_message
deleted_count = 0
# Пытаемся найти сообщение в БД по telegram_message_id
async with async_session_maker() as session:
# Получаем admin user для deleted_by
admin_user = await UserService.get_user_by_telegram_id(
session,
message.from_user.id
)
if not admin_user:
logger.error(f"Админ {message.from_user.id} не найден в БД")
await message.answer("❌ Ошибка: пользователь не найден")
return
chat_message = await ChatMessageService.get_message_by_telegram_id(
session,
telegram_message_id=replied_msg.message_id
)
# Если нашли broadcast сообщение - удаляем у всех получателей
if chat_message and chat_message.forwarded_message_ids:
bot = message.bot
for user_telegram_id, forwarded_msg_id in chat_message.forwarded_message_ids.items():
try:
await bot.delete_message(
chat_id=int(user_telegram_id),
message_id=forwarded_msg_id
)
deleted_count += 1
except Exception as e:
logger.warning(f"Не удалось удалить сообщение у {user_telegram_id}: {e}")
# Помечаем как удалённое в БД (используем admin_user.id, а не telegram_id)
await ChatMessageService.delete_message(
session,
message_id=chat_message.id,
deleted_by=admin_user.id
)
logger.info(
f"Администратор {message.from_user.id} удалил broadcast сообщение "
f"{replied_msg.message_id} у {deleted_count} получателей"
)
# Удаляем исходное сообщение (на которое ответили)
try:
await replied_msg.delete()
except Exception as e:
logger.warning(f"Не удалось удалить исходное сообщение: {e}")
# Удаляем команду админа
try:
await message.delete()
except Exception as e:
logger.warning(f"Не удалось удалить команду админа: {e}")
# Если было broadcast удаление - показываем статистику
if deleted_count > 0:
try:
status_msg = await message.answer(
f"✅ Сообщение удалено у {deleted_count} получателей",
reply_to_message_id=None
)
# Удаляем статус через 3 секунды
import asyncio
await asyncio.sleep(3)
await status_msg.delete()
except Exception as e:
logger.warning(f"Не удалось показать/удалить статус: {e}")
except Exception as e:
logger.error(f"Ошибка при быстром удалении сообщения: {e}")
try:
# Пытаемся удалить хотя бы команду админа
await message.delete()
except:
pass

343
src/handlers/p2p_chat.py Normal file
View File

@@ -0,0 +1,343 @@
"""Обработчики P2P чата между пользователями"""
from aiogram import Router, F
from aiogram.filters import Command, StateFilter
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from src.core.p2p_services import P2PMessageService
from src.core.services import UserService
from src.core.models import User
from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS
router = Router(name='p2p_chat_router')
class P2PChatStates(StatesGroup):
"""Состояния для P2P чата"""
waiting_for_recipient = State() # Ожидание выбора получателя
chatting = State() # В процессе переписки с пользователем
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
@router.message(Command("chat"))
async def show_chat_menu(message: Message, state: FSMContext):
"""
Главное меню чата
/chat - показать меню с опциями общения
"""
# Очищаем состояние при входе в меню (выход из диалога)
await state.clear()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
return
# Получаем количество непрочитанных сообщений
unread_count = await P2PMessageService.get_unread_count(session, user.id)
# Получаем последние диалоги
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
text = "💬 <b>Чат</b>\n\n"
if unread_count > 0:
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
text += "Выберите действие:"
buttons = [
[InlineKeyboardButton(
text="✉️ Написать пользователю",
callback_data="p2p:select_user"
)],
[InlineKeyboardButton(
text="📋 Мои диалоги",
callback_data="p2p:my_conversations"
)]
]
if is_admin(message.from_user.id):
buttons.append([InlineKeyboardButton(
text="📢 Написать всем (broadcast)",
callback_data="p2p:broadcast"
)])
await message.answer(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@router.callback_query(F.data == "p2p:select_user")
async def select_recipient(callback: CallbackQuery, state: FSMContext):
"""Выбор получателя для P2P сообщения"""
await callback.answer()
async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей кроме себя
users = await UserService.get_all_users(session)
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
if not users:
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
return
# Создаём кнопки с пользователями (по 1 на строку)
buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице
display_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
display_name += f" (карта: {user.club_card_number})"
buttons.append([InlineKeyboardButton(
text=display_name,
callback_data=f"p2p:user:{user.id}"
)])
buttons.append([InlineKeyboardButton(
text="« Назад",
callback_data="p2p:back_to_menu"
)])
await callback.message.edit_text(
"👥 <b>Выберите пользователя:</b>\n\n"
"Кликните на пользователя, чтобы начать диалог",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@router.callback_query(F.data.startswith("p2p:user:"))
async def start_conversation(callback: CallbackQuery, state: FSMContext):
"""Начать диалог с выбранным пользователем"""
await callback.answer()
user_id = int(callback.data.split(":")[2])
async with async_session_maker() as session:
recipient = await session.get(User, user_id)
if not recipient:
await callback.message.edit_text("❌ Пользователь не найден")
return
sender = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
# Получаем последние 10 сообщений из диалога
messages = await P2PMessageService.get_conversation(
session,
sender.id,
recipient.id,
limit=10
)
# Сохраняем ID получателя в состоянии
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
await state.set_state(P2PChatStates.chatting)
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
if messages:
text += "📝 <b>Последние сообщения:</b>\n\n"
for msg in reversed(messages[-5:]): # Последние 5 сообщений
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
text += f"{sender_name}: {msg_text}\n"
text += "\n"
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
text += "⚠️ <b>Важно:</b> В режиме диалога все сообщения отправляются только собеседнику.\n"
text += "Для выхода в общий чат используйте кнопку ниже или команду /chat"
buttons = [[InlineKeyboardButton(
text="« Завершить диалог",
callback_data="p2p:end_conversation"
)]]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@router.callback_query(F.data == "p2p:my_conversations")
async def show_conversations(callback: CallbackQuery):
"""Показать список диалогов"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
if not conversations:
await callback.message.edit_text(
"📭 У вас пока нет диалогов\n\n"
"Используйте /chat чтобы написать кому-нибудь"
)
return
text = "📋 <b>Ваши диалоги:</b>\n\n"
buttons = []
for peer, last_msg, unread in conversations:
peer_name = f"@{peer.username}" if peer.username else peer.first_name
# Иконка в зависимости от непрочитанных
icon = "🔴" if unread > 0 else "💬"
# Превью последнего сообщения
preview = last_msg.text[:30] + "..." if last_msg.text and len(last_msg.text) > 30 else (last_msg.text or f"[{last_msg.message_type}]")
button_text = f"{icon} {peer_name}"
if unread > 0:
button_text += f" ({unread})"
buttons.append([InlineKeyboardButton(
text=button_text,
callback_data=f"p2p:user:{peer.id}"
)])
text += f"{icon} <b>{peer_name}</b>\n"
text += f" {preview}\n"
if unread > 0:
text += f" 📨 Непрочитанных: {unread}\n"
text += "\n"
buttons.append([InlineKeyboardButton(
text="« Назад",
callback_data="p2p:back_to_menu"
)])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@router.callback_query(F.data == "p2p:end_conversation")
async def end_conversation(callback: CallbackQuery, state: FSMContext):
"""Завершить текущий диалог"""
await callback.answer("Диалог завершён")
await state.clear()
await callback.message.edit_text(
"✅ Диалог завершён\n\n"
"Используйте /chat чтобы открыть меню чата"
)
@router.callback_query(F.data == "p2p:back_to_menu")
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
"""Вернуться в главное меню"""
await callback.answer()
# Имитируем команду /chat
fake_message = callback.message
fake_message.from_user = callback.from_user
await show_chat_menu(fake_message, state)
# Обработчик сообщений в состоянии chatting
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
async def handle_p2p_message(message: Message, state: FSMContext):
"""Обработка P2P сообщения от пользователя"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[P2P] handle_p2p_message вызван: user={message.from_user.id}, в состоянии P2P chatting")
data = await state.get_data()
recipient_id = data.get("recipient_id")
recipient_telegram_id = data.get("recipient_telegram_id")
if not recipient_id or not recipient_telegram_id:
await message.answer("❌ Ошибка: получатель не найден. Начните диалог заново с /chat")
await state.clear()
return
async with async_session_maker() as session:
sender = await UserService.get_user_by_telegram_id(session, message.from_user.id)
sender_name = f"@{sender.username}" if sender.username else sender.first_name
# Определяем тип сообщения
message_type = "text"
text = message.text
file_id = None
if message.photo:
message_type = "photo"
file_id = message.photo[-1].file_id
text = message.caption
elif message.video:
message_type = "video"
file_id = message.video.file_id
text = message.caption
elif message.document:
message_type = "document"
file_id = message.document.file_id
text = message.caption
# Отправляем сообщение получателю
try:
if message_type == "text":
sent = await message.bot.send_message(
recipient_telegram_id,
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}",
parse_mode="HTML"
)
elif message_type == "photo":
sent = await message.bot.send_photo(
recipient_telegram_id,
photo=file_id,
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML"
)
elif message_type == "video":
sent = await message.bot.send_video(
recipient_telegram_id,
video=file_id,
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}",
parse_mode="HTML"
)
elif message_type == "document":
sent = await message.bot.send_document(
recipient_telegram_id,
document=file_id,
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}",
parse_mode="HTML"
)
# Сохраняем в БД
await P2PMessageService.send_message(
session,
sender_id=sender.id,
recipient_id=recipient_id,
message_type=message_type,
text=text,
file_id=file_id,
sender_message_id=message.message_id,
recipient_message_id=sent.message_id
)
await message.answer("✅ Сообщение доставлено")
except Exception as e:
await message.answer(f"Не удалось доставить сообщение: {e}")

View File

@@ -304,3 +304,97 @@ async def redraw_lottery(message: Message):
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data.startswith("confirm_win_"))
async def confirm_winner_callback(callback_query):
"""Обработка подтверждения выигрыша победителем"""
from aiogram.types import CallbackQuery
winner_id = int(callback_query.data.split("_")[-1])
async with async_session_maker() as session:
# Получаем информацию о победителе
winner_result = await session.execute(
select(Winner).where(Winner.id == winner_id)
)
winner = winner_result.scalar_one_or_none()
if not winner:
await callback_query.answer("❌ Победитель не найден", show_alert=True)
return
if winner.is_claimed:
await callback_query.answer(
"✅ Этот выигрыш уже подтвержден!",
show_alert=True
)
return
# Проверяем, что пользователь является владельцем счёта
if winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if not owner or owner.telegram_id != callback_query.from_user.id:
await callback_query.answer(
"❌ Вы не являетесь владельцем этого счёта",
show_alert=True
)
return
# Проверяем срок действия (24 часа с момента создания winner)
if winner.created_at:
time_since_creation = datetime.now(timezone.utc) - winner.created_at
if time_since_creation > timedelta(hours=24):
await callback_query.answer(
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
show_alert=True
)
return
# Подтверждаем выигрыш
winner.is_claimed = True
winner.claimed_at = datetime.now(timezone.utc)
await session.commit()
# Получаем данные о розыгрыше
lottery = await LotteryService.get_lottery(session, winner.lottery_id)
# Отправляем подтверждение пользователю
confirmation_text = (
f"✅ **Выигрыш подтвержден!**\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 Счет: {winner.account_number}\n\n"
f"📞 С вами свяжется администратор для вручения приза.\n"
f"Спасибо за участие!"
)
await callback_query.message.edit_text(
confirmation_text,
parse_mode="Markdown"
)
# Уведомляем админов
for admin_id in ADMIN_IDS:
try:
admin_text = (
f"✅ **Подтверждение выигрыша**\n\n"
f"👤 Пользователь: {callback_query.from_user.full_name} "
f"(@{callback_query.from_user.username or 'нет username'})\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 Счет: {winner.account_number}"
)
from aiogram import Bot
from src.core.config import BOT_TOKEN
bot = Bot(token=BOT_TOKEN)
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)

View File

@@ -131,8 +131,8 @@ class IBotController(ABC):
pass
@abstractmethod
async def handle_admin_panel(self, callback):
"""Обработать admin panel"""
async def handle_active_lotteries(self, callback):
"""Обработать показ активных розыгрышей"""
pass
@@ -154,26 +154,11 @@ class IKeyboardBuilder(ABC):
"""Интерфейс создания клавиатур"""
@abstractmethod
def get_main_keyboard(self, is_admin: bool):
def get_main_keyboard(self, is_admin: bool, is_registered: bool = False):
"""Получить главную клавиатуру"""
pass
@abstractmethod
def get_admin_keyboard(self):
"""Получить админскую клавиатуру"""
pass
@abstractmethod
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool):
"""Получить клавиатуру для розыгрыша"""
pass
@abstractmethod
def get_lottery_management_keyboard(self):
"""Получить клавиатуру управления розыгрышами"""
pass
@abstractmethod
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
"""Получить клавиатуру для выбора розыгрыша для проведения"""
pass

View File

@@ -99,26 +99,106 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str:
def parse_accounts_from_message(text: str) -> List[str]:
"""
Извлекает все валидные номера счетов из текста сообщения
Извлекает все валидные номера счетов из текста сообщения.
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
Также обрабатывает многострочный текст из кабинета:
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
"Viposnova 16-11-2025 22:19:36
17-24-66-42-38-31-53
0.00 2918"
Args:
text: Текст сообщения
Returns:
List[str]: Список найденных и отформатированных номеров счетов
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
"""
if not text:
return []
accounts = []
# Ищем паттерны счетов в тексте (7 пар цифр)
pattern = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches = re.findall(pattern, text)
for match in matches:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
accounts.append(formatted)
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
lines = text.strip().split('\n')
current_record = []
records = []
for line in lines:
stripped = line.strip()
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
if stripped.startswith('Viposnova') and current_record:
records.append(' '.join(current_record))
current_record = [stripped]
else:
current_record.append(stripped)
# Добавляем последнюю запись
if current_record:
records.append(' '.join(current_record))
# Обрабатываем каждую запись
for record in records:
parts = record.split()
# Ищем счет в записи
account_number = None
account_idx = None
for i, part in enumerate(parts):
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
account_number = format_account_number(part)
account_idx = i
break
if not account_number or account_number in accounts:
continue
# Ищем клубную карту (4-значное число после счета)
card = None
if account_idx is not None:
for j in range(account_idx + 1, len(parts)):
if re.match(r'^\d{4}$', parts[j]):
card = parts[j]
break
# Добавляем результат
if card:
full_account = f"{card} {account_number}"
if full_account not in accounts:
accounts.append(full_account)
else:
accounts.append(account_number)
# Если построчная обработка ничего не нашла, используем старый метод
if not accounts:
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
# Находим все совпадения с картой и удаляем их из текста
text_copy = text
for match in re.finditer(pattern_with_card, text):
card = match.group(1)
account = match.group(2)
formatted = format_account_number(account)
if formatted:
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
# Удаляем это совпадение из копии текста, чтобы не найти повторно
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches_only = re.findall(pattern_only_account, text_copy)
for match in matches_only:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
is_duplicate = any(formatted in acc for acc in accounts)
if not is_duplicate:
accounts.append(formatted)
return accounts

138
src/utils/notifications.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Модуль для отправки уведомлений победителям
"""
import logging
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..core.models import Winner, User
from ..core.services import LotteryService
from ..core.registration_services import AccountService, WinnerNotificationService
from ..core.config import ADMIN_IDS
logger = logging.getLogger(__name__)
async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int):
"""
Асинхронно отправить уведомления победителям с кнопкой подтверждения.
Вызывается после проведения розыгрыша.
Args:
bot: Экземпляр бота для отправки сообщений
session: Сессия БД
lottery_id: ID розыгрыша
"""
# Получаем информацию о розыгрыше
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
logger.error(f"Розыгрыш {lottery_id} не найден")
return
# Получаем всех победителей из БД
winners_result = await session.execute(
select(Winner).where(Winner.lottery_id == lottery_id)
)
winners = winners_result.scalars().all()
logger.info(f"Найдено {len(winners)} победителей для розыгрыша {lottery_id}")
for winner in winners:
try:
# Если у победителя есть account_number, ищем владельца
if winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
verification = await WinnerNotificationService.create_verification_token(
session,
winner.id
)
# Формируем сообщение с кнопкой подтверждения
message = (
f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 **Выигрышный счет: {winner.account_number}**\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n"
f"Если вы не подтвердите в течение 24 часов, "
f"приз будет разыгран заново.\n\n"
f" Если у вас несколько выигрышных счетов, "
f"подтвердите каждый из них отдельно."
)
# Создаем кнопку подтверждения с указанием счета
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"✅ Подтвердить счет {winner.account_number}",
callback_data=f"confirm_win_{winner.id}"
)],
[InlineKeyboardButton(
text="📞 Связаться с администратором",
url=f"tg://user?id={ADMIN_IDS[0]}"
)]
])
# Отправляем уведомление с кнопкой
await bot.send_message(
owner.telegram_id,
message,
reply_markup=keyboard,
parse_mode="Markdown"
)
# Отмечаем, что уведомление отправлено
winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
else:
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
# Если победитель - обычный пользователь (старая система)
elif winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user and user.telegram_id:
message = (
f"🎉 Поздравляем! Вы выиграли!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n\n"
f"Свяжитесь с администратором для получения приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="📞 Связаться с администратором",
url=f"tg://user?id={ADMIN_IDS[0]}"
)]
])
await bot.send_message(
user.telegram_id,
message,
reply_markup=keyboard
)
winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
else:
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
except Exception as e:
logger.error(f"❌ Ошибка при отправке уведомления победителю {winner.id}: {e}")
continue
logger.info(f"Завершена отправка уведомлений для розыгрыша {lottery_id}")

View File

@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""
Упрощенная версия main.py для диагностики
"""
import asyncio
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
async def test_imports():
"""Тест импортов по порядку"""
try:
logger.info("1. Тест импорта config...")
from src.core.config import BOT_TOKEN, ADMIN_IDS, DATABASE_URL
logger.info(f"✅ Config OK. BOT_TOKEN: {BOT_TOKEN[:10]}..., ADMIN_IDS: {ADMIN_IDS}")
logger.info("2. Тест импорта aiogram...")
from aiogram import Bot, Dispatcher
logger.info("✅ Aiogram OK")
logger.info("3. Тест создания бота...")
bot = Bot(token=BOT_TOKEN)
logger.info("✅ Bot created OK")
logger.info("4. Тест импорта database...")
from src.core.database import async_session_maker, init_db
logger.info("✅ Database imports OK")
logger.info("5. Тест подключения к БД...")
async with async_session_maker() as session:
logger.info("✅ Database connection OK")
logger.info("6. Тест импорта services...")
from src.core.services import UserService, LotteryService
logger.info("✅ Services OK")
logger.info("7. Тест импорта handlers...")
from src.handlers.registration_handlers import router as registration_router
logger.info("✅ Registration handlers OK")
from src.handlers.admin_panel import admin_router
logger.info("✅ Admin panel OK")
logger.info("8. Тест создания диспетчера...")
dp = Dispatcher()
dp.include_router(registration_router)
dp.include_router(admin_router)
logger.info("✅ Dispatcher OK")
logger.info("9. Тест получения информации о боте...")
bot_info = await bot.get_me()
logger.info(f"✅ Bot info: {bot_info.username} ({bot_info.first_name})")
await bot.session.close()
logger.info("Все тесты пройдены успешно!")
except Exception as e:
logger.error(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(test_imports())

View File

@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для тестирования функциональности бота
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from src.core.database import async_session_maker
from src.core.models import User, Lottery
from sqlalchemy import select
async def test_database_connectivity():
"""Тест подключения к базе данных"""
print("🔌 Тестируем подключение к базе данных...")
async with async_session_maker() as session:
# Проверяем подключение
result = await session.execute(select(1))
print("✅ Подключение к PostgreSQL работает")
# Проверяем количество пользователей
users_count = await session.execute(select(User))
users = users_count.scalars().all()
print(f"📊 В базе {len(users)} пользователей")
# Проверяем количество лотерей
lotteries_count = await session.execute(select(Lottery))
lotteries = lotteries_count.scalars().all()
print(f"🎰 В базе {len(lotteries)} лотерей")
async def test_bot_imports():
"""Тест импортов бота"""
print("🔄 Тестируем импорты модулей...")
try:
from src.handlers.registration_handlers import router as registration_router
print("✅ registration_router импортирован")
from src.handlers.admin_panel import admin_router
print("✅ admin_router импортирован")
from src.handlers.account_handlers import account_router
print("✅ account_router импортирован")
from src.core.config import BOT_TOKEN
print("✅ BOT_TOKEN получен из конфигурации")
except Exception as e:
print(f"❌ Ошибка импорта: {e}")
return False
return True
async def main():
"""Основная функция тестирования"""
print("🤖 Тестирование функциональности лотерейного бота")
print("=" * 50)
# Тест импортов
imports_ok = await test_bot_imports()
if imports_ok:
print("\n")
# Тест базы данных
await test_database_connectivity()
print("\n" + "=" * 50)
print("✅ Тестирование завершено")
if __name__ == "__main__":
asyncio.run(main())