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
This commit is contained in:
2025-11-17 06:05:48 +00:00
35 changed files with 11617 additions and 416 deletions

1
.bot.pid Normal file
View File

@@ -0,0 +1 @@
1060744

19
.env.prod Normal file
View File

@@ -0,0 +1,19 @@
# Пример конфигурации для продакшн-окружения
# Скопируйте этот файл в .env.prod и заполните реальными значениями
# Telegram Bot Token
BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
# PostgreSQL настройки
POSTGRES_DB=bot_db
POSTGRES_USER=trevor
POSTGRES_PASSWORD=Cl0ud_1985!
# Database URL для бота (используется внутри контейнера)
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

148
ADMIN_PANEL_STRUCTURE.md Normal file
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
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
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. Примечания
- Все изменения обратно совместимы
- Логика работы не изменилась, только структура
- Бот работает стабильно
- Код стал чище и понятнее

281
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/)

217
Makefile
View File

@@ -149,4 +149,219 @@ 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-build:
@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 "⚠️ Перед продолжением убедитесь, что отредактировали .env.prod!"
@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

46
QUICK_START.txt Normal file
View File

@@ -0,0 +1,46 @@
╔════════════════════════════════════════════════════════════════╗
║ 🤖 УПРАВЛЕНИЕ БОТОМ - ШПАРГАЛКА ║
╚════════════════════════════════════════════════════════════════╝
⚡ БЫСТРЫЕ КОМАНДЫ:
make bot-start → Запустить бота
make bot-stop → Остановить бота
make bot-restart → Перезапустить бота
make bot-status → Проверить состояние
make bot-logs → Смотреть логи (Ctrl+C для выхода)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ ПРОБЛЕМА: Бот не реагирует на команды?
ПРИЧИНА: Запущено несколько экземпляров бота одновременно
РЕШЕНИЕ:
1. make bot-restart (перезапустит правильно)
2. make bot-status (проверит что запущен только один)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 ДИАГНОСТИКА:
Проверить процессы:
ps aux | grep "python main.py" | grep -v grep
(Должна быть ОДНА строка!)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📁 ФАЙЛЫ:
Логи: /tmp/bot_single.log
PID: .bot.pid
Скрипт: ./bot_control.sh
Документ: BOT_MANAGEMENT.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
❌ НИКОГДА НЕ ИСПОЛЬЗУЙ: make run (для продакшена)
✅ ВСЕГДА ИСПОЛЬЗУЙ: make bot-start
╚════════════════════════════════════════════════════════════════╝

101
deploy.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/bin/bash
# Скрипт быстрого развертывания бота в продакшн через Docker
set -e # Остановка при ошибке
echo "🚀 Быстрое развертывание lottery bot в продакшн"
echo "================================================"
echo ""
# Проверка наличия Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker не установлен!"
echo "Установите Docker: https://docs.docker.com/get-docker/"
exit 1
fi
# Проверка наличия Docker Compose
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose не установлен!"
echo "Установите Docker Compose: https://docs.docker.com/compose/install/"
exit 1
fi
echo "✅ Docker и Docker Compose установлены"
echo ""
# Проверка .env.prod
if [ ! -f .env.prod ]; then
echo "⚠️ Файл .env.prod не найден"
if [ -f .env.prod.example ]; then
echo "📄 Создаю .env.prod из примера..."
cp .env.prod.example .env.prod
echo ""
echo "⚠️ ВНИМАНИЕ!"
echo "Отредактируйте файл .env.prod и укажите:"
echo " - BOT_TOKEN (токен от @BotFather)"
echo " - POSTGRES_PASSWORD (надежный пароль для БД)"
echo " - DATABASE_URL (обновите пароль в строке подключения)"
echo " - ADMIN_IDS (ваш Telegram ID)"
echo ""
read -p "Нажмите Enter после редактирования .env.prod..."
else
echo "❌ Файл .env.prod.example не найден!"
exit 1
fi
fi
echo "✅ Конфигурация найдена"
echo ""
# Создание необходимых директорий
echo "📁 Создание директорий..."
mkdir -p logs backups data
echo "✅ Директории созданы"
echo ""
# Сборка образа
echo "🔨 Сборка Docker образа..."
docker-compose build --no-cache
echo "✅ Образ собран"
echo ""
# Запуск контейнеров
echo "🚀 Запуск контейнеров..."
docker-compose --env-file .env.prod up -d
echo "✅ Контейнеры запущены"
echo ""
# Ожидание запуска БД
echo "⏳ Ожидание запуска базы данных..."
sleep 10
# Применение миграций
echo "⬆️ Применение миграций..."
docker-compose exec -T bot alembic upgrade head || {
echo "⚠️ Миграции не применены (возможно БД уже актуальна)"
}
echo ""
# Статус
echo "📊 Статус контейнеров:"
docker-compose ps
echo ""
# Проверка логов
echo "📋 Последние логи бота:"
docker-compose logs --tail=20 bot
echo ""
echo "✅ Развертывание завершено!"
echo ""
echo "📝 Полезные команды:"
echo " make docker-logs - Просмотр логов"
echo " make docker-status - Статус контейнеров"
echo " make docker-restart - Перезапуск"
echo " make docker-down - Остановка"
echo " make docker-db-backup - Бэкап БД"
echo ""
echo "🎉 Бот запущен и готов к работе!"

View File

@@ -1,137 +1,65 @@
# 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
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# PostgreSQL база данных
postgres:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: lottery_postgres
container_name: lottery_db
restart: unless-stopped
environment:
- POSTGRES_DB=lottery_bot
- POSTGRES_USER=lottery
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot_db}
POSTGRES_USER: ${POSTGRES_USER:-lottery_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- lottery_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lottery -d lottery_bot"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user} -d ${POSTGRES_DB:-lottery_bot_db}"]
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
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
driver: local
bot_data:
driver: local
networks:
lottery_network:
name: lottery_network
driver: bridge
driver: bridge

76
generate_test_accounts.py Executable file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Генератор тестовых счетов для проверки производительности розыгрыша
"""
import random
def generate_account_number():
"""Генерирует случайный номер счета в формате XX-XX-XX-XX-XX-XX-XX"""
parts = []
for _ in range(7):
part = f"{random.randint(0, 99):02d}"
parts.append(part)
return "-".join(parts)
def generate_accounts(count, card_numbers=None):
"""
Генерирует список уникальных счетов
Args:
count: Количество счетов для генерации
card_numbers: Список номеров карт (опционально)
Returns:
List[str]: Список счетов
"""
accounts = set()
while len(accounts) < count:
account = generate_account_number()
# Добавляем с картой или без
if card_numbers and random.random() > 0.3: # 70% с картой
card = random.choice(card_numbers)
full_account = f"{card} {account}"
else:
full_account = account
accounts.add(full_account)
return list(accounts)
def save_to_file(accounts, filename):
"""Сохраняет счета в файл"""
with open(filename, 'w', encoding='utf-8') as f:
for account in accounts:
f.write(account + '\n')
print(f"✅ Сохранено {len(accounts)} счетов в файл {filename}")
def main():
"""Главная функция"""
print("🎲 Генератор тестовых счетов для розыгрыша\n")
# Параметры
counts = [100, 500, 1000, 2000, 5000]
card_numbers = ['2521', '2522', '2523', '2524', '2525']
for count in counts:
print(f"Генерация {count} счетов...")
accounts = generate_accounts(count, card_numbers)
filename = f"test_accounts_{count}.txt"
save_to_file(accounts, filename)
print("\n✅ Генерация завершена!")
print("\nИспользование:")
print("1. Скопируйте содержимое нужного файла")
print("2. В боте: Управление розыгрышами → Выберите розыгрыш → Участники → Добавить массово")
print("3. Вставьте содержимое файла")
print("4. Проведите розыгрыш и проверьте время выполнения")
if __name__ == "__main__":
main()

82
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(
@@ -57,53 +59,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 +85,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 +111,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:

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,14 +8,16 @@ 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")],
@@ -27,40 +29,18 @@ 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="<EFBFBD> Управление участниками", callback_data="admin_participants")],
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
[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,21 @@ 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))
# Получаем информацию о регистрации пользователя
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
)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
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)
)

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,

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

@@ -147,6 +147,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)
@@ -539,6 +556,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 +567,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 +672,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

@@ -1,6 +1,7 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
import logging
from aiogram import Router, F
from aiogram.types import (
CallbackQuery, Message, InlineKeyboardButton, InlineKeyboardMarkup
@@ -17,6 +18,8 @@ from ..core.services import UserService, LotteryService, ParticipationService
from ..core.config import ADMIN_IDS
from ..core.models import User, Lottery, Participation, Account
logger = logging.getLogger(__name__)
# Состояния для админки
class AdminStates(StatesGroup):
@@ -406,7 +409,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 +1354,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 +1479,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:
# Массовое удаление по номерам счетов
@@ -2591,8 +2593,8 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша"""
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 +2618,73 @@ 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"⚠️ <b>Подтверждение проведения розыгрыша</b>\n\n"
text += f"🎲 <b>Розыгрыш:</b> {lottery.title}\n"
text += f"👥 <b>Участников:</b> {participants_count}\n"
text += f"🏆 <b>Призов:</b> {prizes_count}\n\n"
if lottery.prizes:
text += "<b>Призы:</b>\n"
for i, prize in enumerate(lottery.prizes, 1):
text += f"{i}. {prize}\n"
text += "\n"
text += "❗️ <b>Внимание:</b> После проведения розыгрыша результаты нельзя будет изменить!\n\n"
text += "Продолжить?"
buttons = [
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=f"admin_conduct_confirmed_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
]
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша после подтверждения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
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)
if winners_dict:
# Отправляем уведомления победителям
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 +2698,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=[

View File

@@ -105,6 +105,10 @@ 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'}")
# Проверяем является ли это командой
if message.text and message.text.startswith('/'):
# Список команд, которые НЕ нужно пересылать
@@ -171,11 +175,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':
# Режим пересылки в канал
@@ -237,7 +243,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:
@@ -289,7 +297,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:
@@ -341,7 +351,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:
@@ -393,7 +405,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:
@@ -444,7 +458,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:
@@ -494,7 +510,9 @@ async def handle_voice_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:

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

@@ -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}")

21
test_accounts.txt Normal file
View File

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

100
test_accounts_100.txt Normal file
View File

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

1000
test_accounts_1000.txt Normal file

File diff suppressed because it is too large Load Diff

2000
test_accounts_2000.txt Normal file

File diff suppressed because it is too large Load Diff

500
test_accounts_500.txt Normal file
View File

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

5000
test_accounts_5000.txt Normal file

File diff suppressed because it is too large Load Diff