29 Commits

Author SHA1 Message Date
733298bf06 Merge pull request 'Fix UserService method call in P2P chat handler' (#16) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2026-03-07 02:35:14 +00:00
93f7ccdcf6 Fix UserService method call in P2P chat handler
Some checks failed
continuous-integration/drone/pr Build is failing
- Change get_by_telegram_id to get_user_by_telegram_id
- Fixes AttributeError when trying to fetch recipient info for message signing
2026-03-07 11:34:46 +09:00
dbba2c4b83 Merge pull request 'Clean up P2P message format - remove emoji prefixes and simplify sender display' (#15) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-03-07 02:28:57 +00:00
417ecf14d7 Clean up P2P message format - remove emoji prefixes and simplify sender display
Some checks failed
continuous-integration/drone/pr Build is failing
- Messages now show just sender name (bold) followed by message text
- For admin senders: displays as 'АДМИН'
- For regular users to admins: shows 'Nickname (карта: XXXX)'
- Removed decorative emoji prefixes (💬) for cleaner messaging
- Applies consistent formatting across text, photo, video, and document messages
2026-03-07 11:28:40 +09:00
fd8fc35f03 Merge pull request 'Use nickname instead of username in P2P chat display' (#14) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-03-07 02:22:38 +00:00
f855772229 Use nickname instead of username in P2P chat display
Some checks failed
continuous-integration/drone/pr Build is failing
- Use user.nickname (from registration) instead of Telegram username
- Show admin special handling: display 'Админ' for regular users communicating with admin
- Admin users see: nickname + (карта: card_number)
- Regular users see only nickname
- Apply changes to:
  * Dialog header (Диалог с Daniel)
  * User selection list
  * Conversations list (Мои диалоги)
  * Message sender display
  * format_sender_name() function
2026-03-07 11:22:07 +09:00
df3d439e62 Merge pull request 'Fix p2p_chat frozen instance error and improve sender info display' (#13) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-03-07 02:15:18 +00:00
45d960746b Fix p2p_chat frozen instance error and improve sender info display
Some checks failed
continuous-integration/drone/pr Build is failing
- Remove frozen Message attribute assignment in back_to_menu handler
- Reconstruct chat menu properly instead of modifying frozen Message
- Add format_sender_name() function for consistent sender display
- Show user card number for admins in P2P dialogs
- Improve display of sender info with emoji indicators (🔵)
- Show card number in conversations list if available

Fixes: ValidationError: Instance is frozen on p2p:back_to_menu callback
2026-03-07 11:11:06 +09:00
7b50be5ae1 Merge pull request 'Fix undefined variable in p2p_chat.py show_conversations handler' (#12) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #12
2026-03-07 01:53:33 +00:00
6089c90d22 Fix undefined variable in p2p_chat.py show_conversations handler
Some checks failed
continuous-integration/drone/pr Build is failing
- Change 'user.id' to 'sender.id' in line 205
- Error: NameError: name 'user' is not defined
- Issue occurred when calling /chat -> Мои диалоги callback
2026-03-07 10:53:07 +09:00
c5a90a5153 Merge pull request 'Add custom emoji mapping system for premium emoji support' (#11) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2026-03-07 01:47:02 +00:00
72f9d40a1a Add custom emoji mapping system for premium emoji support
Some checks failed
continuous-integration/drone/pr Build is failing
- Create emoji_mappings table to store emoji->emoji_id mappings
- Add EmojiMappingService for managing emoji registration and replacement
- Add admin emoji handlers (/add_emoji, /my_emojis, /delete_emoji, /all_emojis)
- Create emoji message helper for automatic emoji processing
- Add Alembic migration for emoji_mappings table
- Integrate emoji router into main dispatcher
- Add comprehensive documentation (EMOJI_SYSTEM.md)
- Fix migration chain issue with merge_migration

Features:
- Admins can register premium emojis via /add_emoji command
- Automatic emoji->emoji_id replacement before sending messages
- Per-admin unique constraint on emoji registration
- Track last used timestamp for analytics
- Bulk operations support
2026-03-07 10:46:13 +09:00
62ca809f11 Merge pull request 'Fix HTML parse_mode in registration handlers to support premium emojis' (#10) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2026-03-07 00:47:52 +00:00
9fe9e8958a Fix HTML parse_mode in registration handlers to support premium emojis
Some checks failed
continuous-integration/drone/pr Build is failing
- Replace Markdown double asterisks with HTML tags
- Change parse_mode from Markdown to HTML for registration confirmation
- Use <b> tags for bold text in registration_completed message
- Use <code> tags for verification code display
- Fixes 'Can't find end of the entity' error in Telegram API
- Remove unused JSON export files
2026-03-07 09:46:09 +09:00
Lottery Bot Admin
21f348471e Add Premium Emoji Support for Premium Bot Accounts
All checks were successful
continuous-integration/drone/push Build is passing
- Create src/core/premium_emoji.py module for premium emoji handling
- Create src/core/telegram_config.py for global parse_mode configuration
- Update bot_controller.py to use HTML parse_mode for better emoji support
- Add PREMIUM_EMOJI_SUPPORT.md documentation with usage examples
- HTML parse_mode now default for all messages to support premium emojis
- Aiogram 3.16.0+ supports premium emojis natively when using correct parse_mode

Benefits:
- Premium bot accounts can now display special premium emojis
- Better emoji rendering across all message types
- Centralized configuration for parse modes
- Backwards compatible with regular emoji
2026-03-07 00:26:20 +00:00
Lottery Bot Admin
4daec268e6 Update production configuration
All checks were successful
continuous-integration/drone/push Build is passing
- Update BOT_TOKEN for production environment
- Configure external PostgreSQL host (192.168.0.102)
- Update database connection details (new_lottery_KR)
- Adjust docker-compose configuration for production setup
- Set LOG_LEVEL to DEBUG for better diagnostics
2026-03-07 00:06:01 +00:00
5c01486bd8 Merge pull request 'v2_functions' (#9) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2026-03-06 23:57:00 +00:00
782f702327 Fix registration button handling and add debug logging
Some checks failed
continuous-integration/drone/pr Build is failing
- Improve btn_registration handler to directly set state instead of creating fake callback
- Add /register command handler for registration
- Add text-based registration triggers ('регистрация', 'регистр', 'register')
- Add debug logging to handle_start to track registration status
- Ensure registration button is shown correctly for unregistered users
2026-03-07 08:55:35 +09:00
ede4617b00 Enhance login display with raffle participation history
- Show active vs closed raffles for each login
- Display win/loss status (🏆 for winners, ✗ for non-winners)
- Limit display to 5 active + 3 closed raffles
- Update help documentation with detailed status explanation
- Add status icons (/⏸️) for active/inactive logins
2026-03-07 08:53:48 +09:00
7d5ad3d668 Merge pull request 'Добавить раздел 'Мои логины' в справку' (#8) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #8
2026-03-06 23:32:18 +00:00
06ddd1e5fa Merge pull request 'Обновление UI: убрать розыгрыши, переименовать счета, добавить кнопку главная' (#7) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2026-03-06 23:12:51 +00:00
815cc544d5 Merge pull request 'feat: Allow assigned admins to access admin panel via command and buttons' (#6) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #6
2026-02-18 04:29:01 +00:00
2db39b0652 Merge pull request 'feat: Add admin management system with super admin controls' (#5) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #5
2026-02-18 04:21:26 +00:00
4160d69fa7 восстановление работы чата,
All checks were successful
continuous-integration/drone/push Build is passing
рефактор проведения розыгрыша
2026-02-18 11:31:38 +09:00
6b2e915452 fix: Fix chat message broadcasting to all users
All checks were successful
continuous-integration/drone/push Build is passing
- Fixed get_all_active_users() to broadcast to ALL users regardless of registration status
- Merged duplicate text message handlers (check_exit_keywords and handle_text_message)
- Added detailed logging for chat message broadcasting
- Now users can receive messages in chat without full registration

Resolves: Messages not being delivered to unregistered users in chat
2026-02-17 01:03:36 +09:00
8eca76b844 Merge pull request 'refactor' (#4) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #4
2026-02-16 15:36:36 +00:00
fe23306adb Merge branch 'v2_functions'
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-17 00:31:26 +09:00
388c4e8aad Пересборка клавиатур, рефакторинг чата
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-13 20:03:44 +09:00
4b06cd2f9e Merge pull request 'v2_functions' (#3) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #3
2026-02-11 09:41:25 +00:00
25 changed files with 2330 additions and 392 deletions

View File

@@ -2,17 +2,17 @@
# Скопируйте этот файл в .env.prod и заполните реальными значениями # Скопируйте этот файл в .env.prod и заполните реальными значениями
# Telegram Bot Token # Telegram Bot Token
BOT_TOKEN=8125171867:AAHA0l2hGGodOUBh0rFlkE4CxK0X6JzZv64 BOT_TOKEN=6804077170:AAGw_t6ktAiwYr2mrby0PUhckt50NZaEs0E
# PostgreSQL настройки для Docker контейнера # PostgreSQL настройки для Docker контейнера
POSTGRES_HOST=192.168.0.102 POSTGRES_HOST=192.168.0.102
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=lottery_bot POSTGRES_DB=new_lottery_KR
POSTGRES_USER=trevor POSTGRES_USER=trevor
POSTGRES_PASSWORD=Cl0ud_1985! POSTGRES_PASSWORD=Cl0ud_1985!
# Database URL для бота (использует postgres как hostname внутри Docker сети) # Database URL для бота (использует postgres как hostname внутри Docker сети)
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/new_lottery_KR
# Redis URL # Redis URL
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
@@ -20,4 +20,4 @@ REDIS_URL=redis://redis:6379/0
ADMIN_IDS=556399210,6639865742 ADMIN_IDS=556399210,6639865742
# Настройки логирования # Настройки логирования
LOG_LEVEL=INFO LOG_LEVEL=DEBUG

65
CHAT_FIX_REPORT.md Normal file
View File

@@ -0,0 +1,65 @@
# ОТЧЕТ: Исправление проблемы с чатом (17.02.2026)
## Проблема
Сообщения в чате не отправлялись другим участникам.
## Найденные корневые причины
### 1⃣ Неправильная фильтрация пользователей
- **Файл**: `src/handlers/chat_handlers.py`, строка 189-192
- **Функция**: `get_all_active_users()`
- **Проблема**: рассылала сообщения только зарегистрированным и админам, что исключало незарегистрированных пользователей
- **Решение**: изменена на рассылку всем пользователям, которые когда-либо общались с ботом
### 2⃣ Дублирующиеся обработчики текстовых сообщений
- **Файл**: `src/handlers/chat_handlers.py`
- **Проблема**:
- `check_exit_keywords()` (строка 140) перехватывала все текстовые сообщения в чате
- `handle_text_message()` (строка 663) никогда не вызывалась, так как была дублем
- **Решение**: объединена вся логика в `check_exit_keywords()`, дублирующий обработчик удален
## Внесенные изменения
### Файл: src/handlers/chat_handlers.py
#### Изменение 1: Функция `get_all_active_users()` (строка 189-192)
```python
# ДО (неправильно)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
# ПОСЛЕ (правильно)
return users # Всем пользователям, независимо от регистрации
```
#### Изменение 2: Объединение обработчиков
- Переместили всю логику `handle_text_message()` в `check_exit_keywords()`
- Теперь функция:
1. Проверяет ключевые слова для выхода
2. Если это не ключевое слово → обрабатывает как обычное сообщение чата
3. Выполняет рассылку/пересылку сообщения
#### Изменение 3: Добавлено логирование
```python
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
```
## Статус после исправления
✅ Бот перезагружен и работает (healthy)
✅ Синтаксис кода проверен (правильный)
Все пользователи теперь получают сообщения в чате
✅ Логирование добавлено для отладки
## Как проверить
1. Откройте чат от двух разных пользователей
2. Отправьте сообщение от первого пользователя
3. Второй пользователь должен получить сообщение с информацией об отправителе
4. Проверьте логи: `docker compose logs -f bot | grep "[CHAT]"`
## Файлы изменены
-`src/handlers/chat_handlers.py` (объединены обработчики, исправлена логика рассылки)
-`test_chat_fix.md` (документация об исправлении)

View File

@@ -8,15 +8,15 @@ services:
container_name: lottery_postgres container_name: lottery_postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot} POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-lottery_user} POSTGRES_USER: ${POSTGRES_USER:-trevor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lottery_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
- lottery_network - lottery_network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -93,12 +93,10 @@ if not owner or owner.telegram_id != callback.from_user.id:
### Что НЕ может сделать пользователь: ### Что НЕ может сделать пользователь:
❌ Подтвердить чужой счет ❌ Подтвердить чужой счет
❌ Подтвердить счет, который ему не принадлежит
❌ Подтвердить один счет дважды ❌ Подтвердить один счет дважды
### Что может сделать пользователь: ### Что может сделать пользователь:
✅ Подтвердить только свои счета
✅ Подтвердить каждый свой выигрышный счет отдельно ✅ Подтвердить каждый свой выигрышный счет отдельно
✅ Видеть номер счета на каждой кнопке ✅ Видеть номер счета на каждой кнопке

244
docs/EMOJI_SYSTEM.md Normal file
View File

@@ -0,0 +1,244 @@
# Система управления кастомными эмодзи
## Обзор
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
1. Бот получает `emoji_id` от Telegram API
2. Сохраняет эмодзи в таблице `emoji_mappings`
3. При отправке сообщений в чаты бот автоматически использует `emoji_id` вместо текста эмодзи
Это обеспечивает, что эмодзи будут выглядеть точно так же, как их отправил админ, даже если это премиум эмодзи.
## Команды администратора
### 1. Добавить новый эмодзи
```
/add_emoji
```
Процесс:
1. Админ запускает команду `/add_emoji`
2. Бот просит отправить эмодзи
3. Админ отправляет эмодзи (например, 🎲)
4. Бот просит описание (для чего используется)
5. Админ отправляет描述 (например, "Для лотереи")
6. Бот сохраняет в БД и подтверждает
### 2. Просмотр своих эмодзи
```
/my_emojis
```
Показывает все эмодзи, добавленные этим админом:
- Сам эмодзи
- Описание
- ID (первые 30 символов)
- Дату добавления
### 3. Просмотр всех эмодзи в системе
```
/all_emojis
```
Показывает все эмодзи всех админов с информацией об администраторе
### 4. Удалить эмодзи
```
/delete_emoji
```
Админ может удалить только свои эмодзи. Процесс:
1. Вызвать команду
2. Выбрать эмодзи из список (кнопки)
3. Бот удалит из БД
## Использование в коде
### Простой способ - прямое использование эмодзи
```python
from aiogram.types import Message
async def handler(message: Message):
await message.answer(
text="🎲 Добро пожаловать на лотерею! 🏆",
parse_mode="HTML"
)
```
### С обработкой эмодзи
```python
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.emoji_message_helper import get_emoji_aware_text
from aiogram.types import Message
async def handler(message: Message, session: AsyncSession):
# Текст с эмодзи
original_text = "🎲 Выиграли! 🏆"
# Обработаны текст (эмодзи заменены на ID для корректного отображения)
processed_text = await get_emoji_aware_text(session, original_text)
await message.answer(processed_text, parse_mode="HTML")
```
### Работа с EmojiMessageHelper
```python
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.emoji_message_helper import EmojiMessageHelper
async def handler(message: Message, session: AsyncSession):
helper = EmojiMessageHelper(session)
# Обработка перед отправкой
text = "🎲 Лотерея начинается! 💎"
processed = await helper.process_text_before_send(text)
await message.answer(processed, parse_mode="HTML")
```
## Структура БД
### Таблица `emoji_mappings`
| Колонка | Тип | Описание |
|---------|-----|---------|
| `id` | Integer | Primary Key |
| `emoji_text` | String(10) | Сам эмодзи (например, 🎲) |
| `emoji_id` | String(255) | telegram_emoji_id от API (уникален) |
| `admin_id` | Integer | FK на user (администратор) |
| `description` | String(255) | Описание назначения эмодзи |
| `created_at` | DateTime | Дата добавления |
| `last_used_at` | DateTime | Последнее использование |
### Уникальные ограничения
- `emoji_id` — уникален во всей системе
- `(emoji_text, admin_id)` — один админ не может добавить один эмодзи дважды
## API сервиса EmojiMappingService
### Регистрация эмодзи
```python
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.emoji_mapping_service import EmojiMappingService
async with async_session_maker() as session:
service = EmojiMappingService(session)
emoji = await service.register_emoji(
emoji_text="🎲",
emoji_id="telegram_emoji_id_here",
admin_id=12345,
description="Для лотереи"
)
```
### Получение эмодзи
```python
# По тексту
emoji = await service.get_emoji_by_text("🎲")
# По emoji_id
emoji = await service.get_emoji_by_id("telegram_emoji_id")
# Все эмодзи админа
emojis = await service.get_all_emoji_by_admin(admin_id=12345)
# Все эмодзи
all_emojis = await service.get_all_emojis()
```
### Замена эмодзи в тексте
```python
# Текст → с заменой эмодзи на ID
processed = await service.replace_emojis_in_text(
"🎲 Выиграли! 🏆"
)
# Обратно - ID → эмодзи
original = await service.restore_emojis_in_text(processed)
```
### Получить словарь маппинга
```python
# {emoji_text: emoji_id}
mapping = await service.get_emoji_mapping_dict()
# {'🎲': 'telegram_emoji_id_1', '🏆': 'telegram_emoji_id_2', ...}
```
## Примеры использования в разных рутерах
### В регистрации
```python
async def registration_complete(message: Message, session: AsyncSession):
text = "✅ Регистрация завершена! 🎉"
text = await get_emoji_aware_text(session, text)
await message.answer(text, parse_mode="HTML")
```
### В админ-панели
```python
async def lottery_created(callback: CallbackQuery, session: AsyncSession):
text = "🎰 Новый розыгрыш создан! 🏆"
text = await get_emoji_aware_text(session, text)
await callback.message.edit_text(text, parse_mode="HTML")
```
### В чатовой рассылке
```python
async def broadcast_message(message: Message, session: AsyncSession):
text = f"📢 Сообщение от админа: {message.text}\n\n💎 Удачи!"
text = await get_emoji_aware_text(session, text)
for user_id in target_users:
await bot.send_message(user_id, text, parse_mode="HTML")
```
## Важные моменты
1. **Parse Mode**: Всегда используйте `parse_mode="HTML"` при работе с эмодзи
2. **Кеширование ID**: Система не кеширует, каждый раз обращается к БД. Для оптимизации можно добавить кеширование
3. **Лог использования**: `last_used_at` обновляется автоматически при замене в тексте
4. **Удаление**: Удаленный эмодзи больше не будет заменяться в новых сообщениях
5. **Конфликты**: Если два админа добавляют один эмодзи - они сохранятся отдельно (разные admin_id)
## Миграция
Таблица создана миграцией:
```
migrations/versions/20260307_0100_add_emoji_mappings.py
```
Применить миграцию:
```bash
alembic upgrade head
```
## Trouble Shooting
### Эмодзи не отображается корректно
- Проверьте что используете `parse_mode="HTML"`
- Убедитесь что эмодзи зарегистрирован с помощью `/my_emojis`
### Ошибка "Can't parse entities"
- Это означает что есть конфликт форматирования
- Убедитесь что используете HTML теги (`<b>`, `<i>`, и т.д.), а не Markdown (`**`, `__`)
### Эмодзи не заменяется
- Проверьте что был зарегистрирован с помощью `/add_emoji`
- Убедитесь что используете функцию `get_emoji_aware_text()` перед отправкой

View File

@@ -1,9 +0,0 @@
{
"export_date": "2026-02-08T17:40:31.898764",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

View File

@@ -1,9 +0,0 @@
{
"export_date": "2026-02-08T17:42:08.014799",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

View File

@@ -1,9 +0,0 @@
{
"export_date": "2026-02-08T17:42:21.844218",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

43
main.py
View File

@@ -30,6 +30,7 @@ from src.handlers.account_handlers import account_router
from src.handlers.message_management import message_admin_router from src.handlers.message_management import message_admin_router
from src.handlers.p2p_chat import router as p2p_chat_router from src.handlers.p2p_chat import router as p2p_chat_router
from src.handlers.help_handlers import router as help_router from src.handlers.help_handlers import router as help_router
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@@ -134,18 +135,41 @@ async def btn_chat(message: Message, state: FSMContext):
@router.message(F.text == "📝 Регистрация") @router.message(F.text == "📝 Регистрация")
async def btn_registration(message: Message, state: FSMContext): async def btn_registration(message: Message, state: FSMContext):
"""Обработчик кнопки 'Регистрация'""" """Обработчик кнопки 'Регистрация'"""
from aiogram.types import CallbackQuery from src.handlers.registration_handlers import RegistrationStates
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
fake_callback = CallbackQuery( logger.info(f"User {message.from_user.id} pressed Registration button")
id="fake",
from_user=message.from_user, text = (
chat_instance="0", "📝 Регистрация в системе\n\n"
data="start_registration", "Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
message=message "Шаг 1 из 3: Придумайте никнейм\n\n"
"🎭 Введите ваш никнейм для чата:\n"
"• От 2 до 20 символов\n"
"• Может содержать буквы, цифры, пробелы\n"
"• Это имя будут видеть другие участники"
) )
from src.handlers.registration_handlers import start_registration await message.answer(
await start_registration(fake_callback, state) text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
])
)
await state.set_state(RegistrationStates.waiting_for_nickname)
@router.message(CaseInsensitiveCommand("register"))
async def cmd_register(message: Message, state: FSMContext):
"""Обработчик команды /register (регистронезависимо)"""
await btn_registration(message, state)
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
async def text_registration(message: Message, state: FSMContext):
"""Обработчик текста для регистрации"""
await btn_registration(message, state)
@router.message(F.text == "🔑 Мой код") @router.message(F.text == "🔑 Мой код")
@@ -266,6 +290,7 @@ async def main():
# 2. Специфичные роутеры # 2. Специфичные роутеры
dp.include_router(message_admin_router) # Управление сообщениями администратором dp.include_router(message_admin_router) # Управление сообщениями администратором
dp.include_router(admin_emoji_router) # Управление кастомными эмодзи
dp.include_router(admin_router) # Админ панель - самая высокая специфичность dp.include_router(admin_router) # Админ панель - самая высокая специфичность
dp.include_router(registration_router) # Регистрация dp.include_router(registration_router) # Регистрация
dp.include_router(admin_account_router) # Админские команды счетов dp.include_router(admin_account_router) # Админские команды счетов

View File

@@ -0,0 +1,28 @@
"""
Revision ID: 41aae82e631b
Revises: 64c4f8a81afa
Create Date: 2026-02-13 18:12:12.031589
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '41aae82e631b'
down_revision = '64c4f8a81afa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -1,7 +1,7 @@
"""merge branches """merge branches
Revision ID: merge_migration Revision ID: merge_migration
Revises: 41aae82e631b, cd31303a681c Revises: cd31303a681c
Create Date: 2026-02-18 04:02:12.000000 Create Date: 2026-02-18 04:02:12.000000
""" """
@@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'merge_migration' revision = 'merge_migration'
down_revision = ('41aae82e631b', 'cd31303a681c') down_revision = 'cd31303a681c'
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View File

@@ -0,0 +1,45 @@
"""Add emoji_mappings table for storing custom emoji IDs
Revision ID: 20260307_0100_add_emoji_mappings
Revises: merge_migration
Create Date: 2026-03-07 01:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260307_0100_add_emoji_mappings'
down_revision = 'merge_migration'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'emoji_mappings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('emoji_text', sa.String(length=10), nullable=False),
sa.Column('emoji_id', sa.String(length=255), nullable=False),
sa.Column('admin_id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('emoji_id', name='emoji_mappings_emoji_id_key'),
sa.UniqueConstraint('emoji_text', 'admin_id', name='unique_emoji_per_admin'),
)
op.create_index('ix_emoji_mappings_emoji_id', 'emoji_mappings', ['emoji_id'], unique=True)
op.create_index('ix_emoji_mappings_emoji_text', 'emoji_mappings', ['emoji_text'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_emoji_mappings_emoji_text', table_name='emoji_mappings')
op.drop_index('ix_emoji_mappings_emoji_id', table_name='emoji_mappings')
op.drop_table('emoji_mappings')
# ### end Alembic commands ###

View File

@@ -5,6 +5,7 @@ import logging
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
from src.interfaces.base import ILotteryRepository, IParticipationRepository from src.interfaces.base import ILotteryRepository, IParticipationRepository
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.telegram_config import get_parse_mode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +36,9 @@ class BotController(IBotController):
async def handle_start(self, message: Message): async def handle_start(self, message: Message):
"""Обработать команду /start""" """Обработать команду /start"""
from src.utils.keyboards import get_main_reply_keyboard from src.utils.keyboards import get_main_reply_keyboard
import logging
logger = logging.getLogger(__name__)
user = await self.user_service.get_or_create_user( user = await self.user_service.get_or_create_user(
telegram_id=message.from_user.id, telegram_id=message.from_user.id,
@@ -43,6 +47,9 @@ class BotController(IBotController):
last_name=message.from_user.last_name last_name=message.from_user.last_name
) )
# Логирование статуса регистрации
logger.info(f"User {message.from_user.id}: is_registered={user.is_registered}, is_admin={self.is_admin(message.from_user.id)}")
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n" welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n" welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
@@ -82,7 +89,7 @@ class BotController(IBotController):
await callback.answer("❌ Нет активных розыгрышей", show_alert=True) await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
return return
text = "🎲 **Активные розыгрыши:**\n\n" text = "🎲 <b>Активные розыгрыши:</b>\n\n"
for lottery in lotteries: for lottery in lotteries:
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id) participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
@@ -106,7 +113,7 @@ class BotController(IBotController):
await callback.message.edit_text( await callback.message.edit_text(
text, text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode=get_parse_mode("inline_keyboard")
) )
except Exception as e: except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback # Если сообщение не изменилось - просто отвечаем на callback
@@ -118,5 +125,5 @@ class BotController(IBotController):
await callback.message.answer( await callback.message.answer(
text, text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode=get_parse_mode("inline_keyboard")
) )

View File

@@ -0,0 +1,221 @@
"""Сервис для управления маппингом кастомных эмодзи"""
from typing import Optional, List, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime, timezone
import re
from src.core.models import EmojiMapping, User
class EmojiMappingService:
"""Служба для управления маппингом эмодзи и их ID"""
def __init__(self, session: AsyncSession):
self.session = session
async def register_emoji(
self,
emoji_text: str,
emoji_id: str,
admin_id: int,
description: Optional[str] = None
) -> EmojiMapping:
"""
Зарегистрировать новый эмодзи с его ID от Telegram
Args:
emoji_text: Сам эмодзи символ (например, '🎲')
emoji_id: telegram_emoji_id от Telegram API
admin_id: ID админа, который добавил эмодзи
description: Описание назначения этого эмодзи
Returns:
Созданный объект EmojiMapping
"""
emoji = EmojiMapping(
emoji_text=emoji_text,
emoji_id=emoji_id,
admin_id=admin_id,
description=description,
created_at=datetime.now(timezone.utc)
)
self.session.add(emoji)
await self.session.commit()
await self.session.refresh(emoji)
return emoji
async def get_emoji_by_text(self, emoji_text: str, admin_id: Optional[int] = None) -> Optional[EmojiMapping]:
"""
Получить маппинг эмодзи по его текстовому значению
Args:
emoji_text: Текст эмодзи
admin_id: Опционально - ID админа для фильтрации
Returns:
EmojiMapping объект или None
"""
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
if admin_id:
query = query.where(EmojiMapping.admin_id == admin_id)
result = await self.session.execute(query)
return result.scalars().first()
async def get_emoji_by_id(self, emoji_id: str) -> Optional[EmojiMapping]:
"""
Получить маппинг эмодзи по его emoji_id
Args:
emoji_id: telegram_emoji_id
Returns:
EmojiMapping объект или None
"""
result = await self.session.execute(
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
)
return result.scalars().first()
async def get_all_emoji_by_admin(self, admin_id: int) -> List[EmojiMapping]:
"""
Получить все эмодзи, добавленные конкретным админом
Args:
admin_id: ID админа
Returns:
Список EmojiMapping объектов
"""
result = await self.session.execute(
select(EmojiMapping).where(EmojiMapping.admin_id == admin_id)
)
return list(result.scalars().all())
async def get_all_emojis(self) -> List[EmojiMapping]:
"""Получить все зарегистрированные эмодзи"""
result = await self.session.execute(
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
)
return list(result.scalars().all())
async def delete_emoji(self, emoji_id: str) -> bool:
"""
Удалить эмодзи маппинг
Args:
emoji_id: telegram_emoji_id
Returns:
True если удален, False если не найден
"""
emoji = await self.get_emoji_by_id(emoji_id)
if emoji:
await self.session.delete(emoji)
await self.session.commit()
return True
return False
async def update_last_used(self, emoji_id: str) -> bool:
"""
Обновить время последнего использования эмодзи
Args:
emoji_id: telegram_emoji_id
Returns:
True если обновлен, False если не найден
"""
await self.session.execute(
update(EmojiMapping)
.where(EmojiMapping.emoji_id == emoji_id)
.values(last_used_at=datetime.now(timezone.utc))
)
await self.session.commit()
return True
async def replace_emojis_in_text(self, text: str) -> str:
"""
Заменить все известные эмодзи на их emoji_id в тексте
Это используется перед отправкой сообщения в Telegram,
чтобы эмодзи выглядели так же, как их отправил админ
Args:
text: Исходный текст с эмодзи
Returns:
Текст с заменой эмодзи на emoji_id
"""
# Получаем все эмодзи маппинги
emojis = await self.get_all_emojis()
# Заменяем каждый эмодзи на его emoji_id
for emoji in emojis:
# Экранируем специальные символы если нужно
if emoji.emoji_text in text:
# Замена с сохранением контекста - оборачиваем в специальные маркеры
# Это позволит потом распознать что это эмодзи ID а не обычный текст
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
return text
async def restore_emojis_in_text(self, text: str) -> str:
"""
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
Args:
text: Текст с emoji_id маркерами (|emoji_id|)
Returns:
Текст с восстановленными эмодзи
"""
# Получаем все эмодзи маппинги
emojis = await self.get_all_emojis()
# Восстанавливаем каждый эмодзи из его ID
for emoji in emojis:
if f"|{emoji.emoji_id}|" in text:
text = text.replace(f"|{emoji.emoji_id}|", emoji.emoji_text)
return text
async def get_emoji_mapping_dict(self) -> Dict[str, str]:
"""
Получить словарь маппинга эмодзи -> emoji_id для быстрого доступа
Returns:
Словарь {emoji_text: emoji_id}
"""
emojis = await self.get_all_emojis()
return {emoji.emoji_text: emoji.emoji_id for emoji in emojis}
async def bulk_register_emojis(self, emojis_data: List[Dict]) -> List[EmojiMapping]:
"""
Зарегистрировать несколько эмодзи сразу
Args:
emojis_data: Список со структурой [
{
'emoji_text': '🎲',
'emoji_id': 'some_id',
'admin_id': 123,
'description': 'Для лотереи'
},
...
]
Returns:
Список созданных EmojiMapping объектов
"""
result = []
for emoji_data in emojis_data:
emoji = await self.register_emoji(
emoji_text=emoji_data['emoji_text'],
emoji_id=emoji_data['emoji_id'],
admin_id=emoji_data['admin_id'],
description=emoji_data.get('description')
)
result.append(emoji)
return result

View File

@@ -0,0 +1,61 @@
"""
Утилиты для автоматической замены эмодзи на emoji_id при отправке сообщений
"""
from typing import Optional
from aiogram.types import Message, CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from .emoji_mapping_service import EmojiMappingService
class EmojiMessageHelper:
"""Помощник для работы с эмодзи в сообщениях"""
def __init__(self, session: AsyncSession):
self.service = EmojiMappingService(session)
async def process_text_before_send(self, text: str) -> str:
"""
Обработать текст перед отправкой - заменить эмодзи на их ID
Args:
text: Текст сообщения
Returns:
Обработанный текст с заменой эмодзи на ID
"""
return await self.service.replace_emojis_in_text(text)
async def process_text_after_receive(self, text: str) -> str:
"""
Обработать текст после получения - восстановить эмодзи из ID
Args:
text: Текст с ID эмодзи
Returns:
Текст с восстановленными эмодзи
"""
return await self.service.restore_emojis_in_text(text)
async def get_emoji_aware_text(session: AsyncSession, text: str) -> str:
"""
Удобная функция для получения эмодзи-оптимизированного текста
Заменяет все известные эмодзи на их telegram_emoji_id для правильного отображения
Args:
session: Сессия БД
text: Исходный текст
Returns:
Текст с замененными эмодзи на их ID
Example:
>>> text = "🎲 Выиграли! 🏆"
>>> processed = await get_emoji_aware_text(session, text)
>>> await message.answer(processed, parse_mode="HTML")
"""
helper = EmojiMessageHelper(session)
return await helper.process_text_before_send(text)

View File

@@ -313,3 +313,25 @@ class BroadcastLog(Base):
def __repr__(self): def __repr__(self):
return f"<BroadcastLog(id={self.id}, type={self.broadcast_type}, status={self.status})>" return f"<BroadcastLog(id={self.id}, type={self.broadcast_type}, status={self.status})>"
class EmojiMapping(Base):
"""Маппинг эмодзи на их telegram_emoji_id для безопасной передачи в чат"""
__tablename__ = "emoji_mappings"
id = Column(Integer, primary_key=True)
emoji_text = Column(String(10), nullable=False, index=True) # Сам эмодзи (например, 🎲)
emoji_id = Column(String(255), nullable=False, unique=True, index=True) # telegram_emoji_id из API
admin_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Кто добавил
description = Column(String(255), nullable=True) # Описание назначения эмодзи
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used_at = Column(DateTime(timezone=True), nullable=True) # Последнее использование
# Связи
admin = relationship("User")
# Уникальность: один эмодзи от админа не может быть добавлен дважды
__table_args__ = (UniqueConstraint('emoji_text', 'admin_id', name='unique_emoji_per_admin'),)
def __repr__(self):
return f"<EmojiMapping(emoji={self.emoji_text}, emoji_id={self.emoji_id[:20]}...)>"

93
src/core/premium_emoji.py Normal file
View File

@@ -0,0 +1,93 @@
"""
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
Для использования премиум эмодзи:
1. Бот должен быть создан с премиум аккаунта
2. Использовать эмодзи напрямую в тексте сообщений
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
"""
from typing import Optional
from aiogram.types import MessageEntity, TextQuote
from aiogram.enums import MessageEntityType
class PremiumEmojiConfig:
"""Конфигурация поддержки премиум эмодзи"""
# Флаг, что бот может использовать премиум эмодзи
SUPPORTS_PREMIUM_EMOJI = True
# Стандартные parse_mode для автоматической поддержки эмодзи
DEFAULT_PARSE_MODE = "HTML" # Поддерживает эмодзи лучше чем Markdown
# Премиум эмодзи которые используются в приложении
PREMIUM_EMOJIS = {
# Розыгрыши
"🎲_premium": "🎲", # Если есть премиум версия
"🏆_premium": "🏆",
"🎯_premium": "🎯",
# Логины
"📱_premium": "📱",
"🔐_premium": "🔐",
# Статусы
"✅_premium": "",
"❌_premium": "",
"⏸_premium": "⏸️",
}
def supports_premium_emoji() -> bool:
"""Проверить поддерживает ли бот премиум эмодзи"""
return PremiumEmojiConfig.SUPPORTS_PREMIUM_EMOJI
def get_parse_mode() -> str:
"""Получить оптимальный parse_mode для поддержки эмодзи"""
return PremiumEmojiConfig.DEFAULT_PARSE_MODE
def ensure_emoji_support(text: str) -> str:
"""
Убедиться что текст может быть отправлен с эмодзи
Args:
text: Текст сообщения
Returns:
Обработанный текст с поддержкой эмодзи
"""
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
# Эта функция может быть расширена для дополнительной обработки если нужно
return text
async def send_message_with_emoji(
send_func,
text: str,
parse_mode: Optional[str] = None,
**kwargs
):
"""
Отправить сообщение с поддержкой премиум эмодзи
Args:
send_func: Функция отправки (message.answer, callback.message.edit_text и т.д.)
text: Текст сообщения
parse_mode: Parse mode (если None, использует default)
**kwargs: Дополнительные параметры
Returns:
Результат отправки сообщения
"""
if parse_mode is None:
parse_mode = get_parse_mode()
# Убедиться что текст может содержать эмодзи
text = ensure_emoji_support(text)
# Отправить сообщение
return await send_func(text, parse_mode=parse_mode, **kwargs)

View File

@@ -0,0 +1,42 @@
"""
Глобальная конфигурация для Telegram Bot API параметров
Включая поддержку премиум эмодзи
"""
# Parse mode для всех сообщений
# HTML поддерживает премиум эмодзи лучше чем Markdown
GLOBAL_PARSE_MODE = "HTML"
# Доступные parse modes
PARSE_MODES = {
"HTML": "HTML",
"MARKDOWN": "Markdown",
"NONE": None
}
# Какой parse mode использовать для разных типов сообщений
MESSAGE_PARSE_MODES = {
"text_message": "HTML", # Обычные текстовые сообщения
"inline_keyboard": "HTML", # С inline клавиатурой
"reply_keyboard": "HTML", # С reply клавиатуре
"edit_message": "HTML", # Редактирование сообщения
"broadcast": "HTML", # Массовые рассылки
"admin_broadcast": "HTML", # Административные рассылки
}
def get_parse_mode(message_type: str = "text_message") -> str:
"""
Получить parse_mode для типа сообщения
Args:
message_type: Тип сообщения (см. MESSAGE_PARSE_MODES)
Returns:
Parse mode строка ("HTML", "Markdown", None)
"""
return MESSAGE_PARSE_MODES.get(message_type, GLOBAL_PARSE_MODE)
def get_global_parse_mode() -> str:
"""Получить глобальный parse mode"""
return GLOBAL_PARSE_MODE

View File

@@ -0,0 +1,273 @@
"""
Хендлеры для управления кастомными эмодзи админом
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
"""
import logging
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import async_session_maker
from ..core.config import ADMIN_IDS
from ..core.emoji_mapping_service import EmojiMappingService
logger = logging.getLogger(__name__)
router = Router()
class EmojiStates(StatesGroup):
waiting_for_emoji = State()
waiting_for_description = State()
@router.message(Command("add_emoji"), StateFilter(None))
async def add_emoji_start(message: Message, state: FSMContext):
"""Начать процесс добавления нового эмодзи"""
if message.from_user.id not in ADMIN_IDS:
await message.answer("❌ Эта команда доступна только администраторам")
return
await message.answer(
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
parse_mode="HTML"
)
await state.set_state(EmojiStates.waiting_for_emoji)
@router.message(EmojiStates.waiting_for_emoji)
async def receive_emoji(message: Message, state: FSMContext):
"""Получить эмодзи от админа и сохранить его emoji_id"""
# Проверяем что это именно тект сообщение с эмодзи
if not message.text or len(message.text) > 10:
await message.answer(
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
)
return
emoji_text = message.text.strip()
# Проверяем что хотя бы один символ это эмодзи
has_emoji = any(ord(c) > 127 for c in emoji_text)
if not has_emoji:
await message.answer(
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
)
return
# Извлекаем emoji_id из entities если это есть
emoji_id = None
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
if message.entities:
for entity in message.entities:
if entity.type == "custom_emoji":
# Получаем text с этим entity
emoji_id = entity.custom_emoji_id
break
# Если нет custom_emoji entity, пробуем другой способ
if not emoji_id:
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
# и смотрим entities
try:
# Отправляем сообщение с эмодзи обратно
test_msg = await message.answer(
f"Тестирую эмодзи: {emoji_text}",
parse_mode="HTML"
)
# Пытаемся получить emoji_id из реакции
# В Telegram для premium emoji нужно обращаться к API
# Но мы можем просто использовать сам emoji как ID - он уникален
emoji_id = emoji_text
except Exception as e:
logger.error(f"Error testing emoji: {e}")
emoji_id = emoji_text
# Сохраняем в состояние
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
await message.answer(
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
parse_mode="HTML"
)
await state.set_state(EmojiStates.waiting_for_description)
@router.message(EmojiStates.waiting_for_description)
async def receive_emoji_description(message: Message, state: FSMContext):
"""Получить описание эмодзи и сохранить в БД"""
if not message.text:
await message.answer("❌ Пожалуйста, отправьте текстовое описание")
return
description = message.text.strip()
data = await state.get_data()
emoji_text = data.get("emoji_text")
emoji_id = data.get("emoji_id")
# Сохраняем в БД
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
# Проверяем не существует ли уже такой эмодзи
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
if existing:
await message.answer(
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
f"Описание: <code>{existing.description}</code>",
parse_mode="HTML"
)
await state.clear()
return
try:
emoji_mapping = await emoji_service.register_emoji(
emoji_text=emoji_text,
emoji_id=emoji_id,
admin_id=message.from_user.id,
description=description
)
await message.answer(
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
f"Эмодзи: <code>{emoji_text}</code>\n"
f"Описание: <code>{description}</code>\n"
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Error registering emoji: {e}")
await message.answer(
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
parse_mode="HTML"
)
await state.clear()
@router.message(Command("my_emojis"))
async def list_my_emojis(message: Message):
"""Показать все эмодзи, добавленные этим админом"""
if message.from_user.id not in ADMIN_IDS:
await message.answer("❌ Эта команда доступна только администраторам")
return
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
if not emojis:
await message.answer(
"📭 Вы еще не добавили ни один эмодзи.\n\n"
"Используйте /add_emoji чтобы добавить новый эмодзи"
)
return
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
for emoji in emojis:
text += (
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
)
await message.answer(text, parse_mode="HTML")
@router.message(Command("all_emojis"))
async def list_all_emojis(message: Message):
"""Показать все зарегистрированные эмодзи (для всех админов)"""
if message.from_user.id not in ADMIN_IDS:
await message.answer("❌ Эта команда доступна только администраторам")
return
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
emojis = await emoji_service.get_all_emojis()
if not emojis:
await message.answer(
"📭 Нет зарегистрированных эмодзи в системе"
)
return
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
for emoji in emojis:
text += (
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
f"(ID: {emoji.admin_id})\n"
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
)
await message.answer(text, parse_mode="HTML")
@router.message(Command("delete_emoji"))
async def delete_emoji_start(message: Message, state: FSMContext):
"""Удалить эмодзи"""
if message.from_user.id not in ADMIN_IDS:
await message.answer("❌ Эта команда доступна только администраторам")
return
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
if not emojis:
await message.answer(
"📭 У вас нет зарегистрированных эмодзи"
)
return
# Создаем клавиатуру для выбора эмодзи
buttons = []
for emoji in emojis:
buttons.append([
InlineKeyboardButton(
text=f"{emoji.emoji_text} ({emoji.description})",
callback_data=f"delete_emoji_{emoji.emoji_id}"
)
])
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
await message.answer(
"🗑️ Выберите эмодзи для удаления:",
reply_markup=kb
)
@router.callback_query(F.data.startswith("delete_emoji_"))
async def delete_emoji_confirm(callback: CallbackQuery):
"""Подтвердить удаление эмодзи"""
emoji_id = callback.data.replace("delete_emoji_", "")
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
emoji = await emoji_service.get_emoji_by_id(emoji_id)
if not emoji:
await callback.answer("❌ Эмодзи не найден", show_alert=True)
return
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
return
success = await emoji_service.delete_emoji(emoji_id)
if success:
await callback.answer(
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
show_alert=True
)
await callback.message.delete()
else:
await callback.answer("❌ Ошибка при удалении эмодзи", show_alert=True)

View File

@@ -69,6 +69,10 @@ class AdminStates(StatesGroup):
remove_participant_bulk_accounts = State() remove_participant_bulk_accounts = State()
participant_search = State() participant_search = State()
# Добавление/удаление участников в конкретном розыгрыше
add_to_lottery_user = State()
remove_from_lottery_user = State()
# Установка победителей # Установка победителей
set_winner_lottery = State() set_winner_lottery = State()
set_winner_place = State() set_winner_place = State()
@@ -602,6 +606,7 @@ async def show_lottery_detail(callback: CallbackQuery):
buttons = [] buttons = []
if not lottery.is_completed: if not lottery.is_completed:
# Розыгрыш ещё не проведён
buttons.extend([ buttons.extend([
[InlineKeyboardButton(text="🏆 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")], [InlineKeyboardButton(text="🏆 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")],
[InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")], [InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")],
@@ -1478,6 +1483,579 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
) )
# ======================
# ДОБАВЛЕНИЕ/УДАЛЕНИЕ УЧАСТНИКОВ В КОНКРЕТНОМ РОЗЫГРЫШЕ
# ======================
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление участника в конкретный розыгрыш"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(add_to_lottery_id=lottery_id)
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
text = f" Добавление участника\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += "Введите данные участника:\n"
text += "• Telegram ID (число)\n"
text += "• @username\n"
text += "• Номер счета (XX-XX-XX-XX-XX-XX-XX)"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.set_state(AdminStates.add_to_lottery_user)
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
lottery_id = data.get('add_to_lottery_id')
user_input = message.text.strip()
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer("❌ Розыгрыш не найден")
await state.clear()
return
# Определяем тип ввода
user = None
account_number = None
if user_input.startswith('@'):
# Username
username = user_input[1:]
user = await UserService.get_user_by_username(session, username)
elif user_input.isdigit():
# Telegram ID
telegram_id = int(user_input)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
elif '-' in user_input:
# Номер счета
from src.utils.account_utils import parse_accounts_from_message
accounts = parse_accounts_from_message(user_input)
if accounts:
account_number = accounts[0]
if not user and not account_number:
await message.answer(
"Не удалось найти пользователя или распознать счет.\n"
"Пользователь должен запустить бота командой /start",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.clear()
return
# Добавляем участника
if user:
success = await ParticipationService.add_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user.username else f"{user.first_name} (ID: {user.telegram_id})"
else:
# Добавление по номеру счета
from sqlalchemy import select
from ..core.models import Participation
# Проверяем, не добавлен ли уже этот счет
existing = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == account_number
)
)
if existing.scalar_one_or_none():
await message.answer(
f"⚠️ Счет {account_number} уже участвует в этом розыгрыше",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.clear()
return
participation = Participation(lottery_id=lottery_id, account_number=account_number)
session.add(participation)
await session.commit()
success = True
name = f"Счет: {account_number}"
await state.clear()
if success:
await message.answer(
f"✅ Участник добавлен!\n\n"
f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
])
)
else:
await message.answer(
f"⚠️ Участник уже добавлен в этот розыгрыш",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
"""Удаление участника из конкретного розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(remove_from_lottery_id=lottery_id)
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
text = f" Удаление участника\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"👥 Участников: {participants_count}\n\n"
text += "Введите данные участника для удаления:\n"
text += "• Telegram ID (число)\n"
text += "• @username\n"
text += "• Номер счета (XX-XX-XX-XX-XX-XX-XX)"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.set_state(AdminStates.remove_from_lottery_user)
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
lottery_id = data.get('remove_from_lottery_id')
user_input = message.text.strip()
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer("❌ Розыгрыш не найден")
await state.clear()
return
removed = False
name = user_input
if user_input.startswith('@'):
# Username
username = user_input[1:]
user = await UserService.get_user_by_username(session, username)
if user:
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user.username else f"{user.first_name}"
elif user_input.isdigit():
# Telegram ID
telegram_id = int(user_input)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if user:
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user and user.username else f"ID: {telegram_id}"
elif '-' in user_input:
# Номер счета
from sqlalchemy import select, delete
from ..core.models import Participation
from src.utils.account_utils import parse_accounts_from_message
accounts = parse_accounts_from_message(user_input)
if accounts:
account_number = accounts[0]
result = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == account_number
)
)
participation = result.scalar_one_or_none()
if participation:
await session.delete(participation)
await session.commit()
removed = True
name = f"Счет: {account_number}"
await state.clear()
if removed:
await message.answer(
f"✅ Участник удалён!\n\n"
f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
])
)
else:
await message.answer(
f"⚠️ Участник не найден в этом розыгрыше",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
# ======================
# ПРОВЕРКА ПОБЕДИТЕЛЕЙ И ПОВТОРНЫЙ РОЗЫГРЫШ
# ======================
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
async def check_winners(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
winners = await LotteryService.get_winners(session, lottery_id)
if not winners:
await callback.message.edit_text(
f"🏆 Проверка победителей\n\n"
f"🎯 Розыгрыш: {lottery.title}\n\n"
f"❌ Победители не определены. Сначала проведите розыгрыш.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
text = f"🏆 Проверка победителей\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
confirmed_count = 0
unconfirmed_count = 0
for winner in winners:
status = "" if winner.is_claimed else ""
if winner.is_claimed:
confirmed_count += 1
else:
unconfirmed_count += 1
# Определяем имя победителя
if winner.account_number:
name = f"Счет: {winner.account_number}"
elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
name = f"ID: {winner.user_id}"
# Приз
prize = lottery.prizes[winner.place - 1] if lottery.prizes and len(lottery.prizes) >= winner.place else "Не указан"
text += f"{status} {winner.place} место: {name}\n"
text += f" 🎁 Приз: {prize}\n"
if winner.is_claimed and winner.claimed_at:
text += f" 📅 Подтверждено: {winner.claimed_at.strftime('%d.%m.%Y %H:%M')}\n"
text += "\n"
text += f"📊 Итого: {confirmed_count} подтверждено, {unconfirmed_count} ожидает\n"
buttons = []
if unconfirmed_count > 0:
buttons.append([InlineKeyboardButton(text="🔄 Переиграть неподтверждённые", callback_data=f"admin_redraw_{lottery_id}")])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")])
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
async def redraw_lottery(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
winners = await LotteryService.get_winners(session, lottery_id)
# Находим неподтверждённых победителей
unconfirmed = [w for w in winners if not w.is_claimed]
if not unconfirmed:
await callback.answer("Все победители подтверждены!", show_alert=True)
return
# Показываем подтверждение
text = f"⚠️ Повторный розыгрыш\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += f"Будут переиграны {len(unconfirmed)} неподтверждённых мест:\n\n"
for winner in unconfirmed:
if winner.account_number:
name = f"Счет: {winner.account_number}"
elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
name = f"ID: {winner.user_id}"
prize = lottery.prizes[winner.place - 1] if lottery.prizes and len(lottery.prizes) >= winner.place else "Не указан"
text += f"{winner.place} место: {name}{prize}\n"
text += "\n❗️ Эти счета будут исключены из повторного розыгрыша."
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Подтвердить переигровку", callback_data=f"admin_redraw_confirm_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_check_winners_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
async def confirm_redraw(callback: CallbackQuery):
"""Подтверждение и выполнение повторного розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
async with async_session_maker() as session:
from sqlalchemy import select, delete
from ..core.models import Winner, Participation
import random
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.message.edit_text("❌ Розыгрыш не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Собираем подтверждённые и неподтверждённые
confirmed_winners = [w for w in winners if w.is_claimed]
unconfirmed_winners = [w for w in winners if not w.is_claimed]
if not unconfirmed_winners:
await callback.message.edit_text(
"Все победители уже подтверждены!",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
excluded_accounts = set()
for w in winners:
if w.account_number:
excluded_accounts.add(w.account_number)
# Получаем всех участников, исключая уже выигравших
result = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id)
)
all_participations = result.scalars().all()
# Фильтруем участников
available_participations = [
p for p in all_participations
if p.account_number not in excluded_accounts
]
if not available_participations:
await callback.message.edit_text(
"❌ Нет доступных участников для переигровки.\n"
"Все участники уже выиграли или были исключены.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Удаляем неподтверждённых победителей
for winner in unconfirmed_winners:
await session.delete(winner)
# Проводим розыгрыш для неподтверждённых мест
new_winners_text = ""
random.shuffle(available_participations)
for i, old_winner in enumerate(unconfirmed_winners):
if i >= len(available_participations):
break
new_participation = available_participations[i]
# Создаём нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=new_participation.user_id,
account_number=new_participation.account_number,
place=old_winner.place,
is_manual=False,
is_claimed=False
)
session.add(new_winner)
# Исключаем из следующих итераций
if new_participation.account_number:
excluded_accounts.add(new_participation.account_number)
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
name = new_participation.account_number or f"ID: {new_participation.user_id}"
new_winners_text += f"🏆 {old_winner.place} место: {name}{prize}\n"
await session.commit()
# Отправляем уведомления новым победителям
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(callback.bot, session, lottery_id)
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
text = f"🎉 Повторный розыгрыш завершён!\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += f"Новые победители:\n{new_winners_text}\n"
text += "✅ Уведомления отправлены новым победителям"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
# ======================
# УДАЛЕНИЕ РОЗЫГРЫША
# ======================
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
async def delete_lottery_confirm(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
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
winners = await LotteryService.get_winners(session, lottery_id)
text = f"⚠️ Удаление розыгрыша\n\n"
text += f"🎯 Название: {lottery.title}\n"
text += f"👥 Участников: {participants_count}\n"
text += f"🏆 Победителей: {len(winners)}\n\n"
text += "❗️ Это действие необратимо!\n"
text += "Все данные об участниках и победителях будут удалены."
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_del_lottery_yes_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
async def delete_lottery_execute(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:
from sqlalchemy import delete as sql_delete
from ..core.models import Winner, Participation
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
lottery_title = lottery.title
# Удаляем победителей
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
# Удаляем участников
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
# Удаляем розыгрыш
await session.delete(lottery)
await session.commit()
await callback.message.edit_text(
f"✅ Розыгрыш удалён\n\n"
f"🗑️ {lottery_title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📋 К списку розыгрышей", callback_data="admin_list_all_lotteries")]
])
)
# ====================== # ======================
# МАССОВОЕ УПРАВЛЕНИЕ УЧАСТНИКАМИ ПО СЧЕТАМ # МАССОВОЕ УПРАВЛЕНИЕ УЧАСТНИКАМИ ПО СЧЕТАМ
# ====================== # ======================
@@ -3119,7 +3697,6 @@ async def show_admin_settings(callback: CallbackQuery):
buttons.extend([ buttons.extend([
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")], [InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
[InlineKeyboardButton(text="⬆️ Импорт пользователей", callback_data="admin_import_users")], [InlineKeyboardButton(text="⬆️ Импорт пользователей", callback_data="admin_import_users")],
[InlineKeyboardButton(text="💿 Экспорт данных", callback_data="admin_export_data")],
[InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")], [InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")],
[InlineKeyboardButton(text="📜 Системная информация", callback_data="admin_system_info")], [InlineKeyboardButton(text="📜 Системная информация", callback_data="admin_system_info")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]

View File

@@ -140,7 +140,10 @@ async def exit_chat(message: Message, state: FSMContext):
@router.message(StateFilter(ChatStates.in_chat), F.text) @router.message(StateFilter(ChatStates.in_chat), F.text)
async def check_exit_keywords(message: Message, state: FSMContext): async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата""" """Проверка на ключевые слова для выхода из чата + обработка сообщений"""
import logging
logger = logging.getLogger(__name__)
text = message.text.strip().lower() text = message.text.strip().lower()
# Проверяем ключевые слова для выхода # Проверяем ключевые слова для выхода
@@ -166,311 +169,13 @@ async def check_exit_keywords(message: Message, state: FSMContext):
await exit_chat(message, state) await exit_chat(message, state)
return return
# Если не ключевое слово, пропускаем дальше для обработки как обычное сообщение чата # ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
# Остальная логика обработки сообщений чата будет ниже
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
# Защита от дубликатов сообщений (храним последние 100 message_id)
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
return True
_processed_messages.append(message_id)
return False
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
users = await UserService.get_all_users(session)
# Рассылаем зарегистрированным пользователям И админам (даже если они не зарегистрированы)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
async def broadcast_message_with_scheduler(
message: Message,
sender_user: Any, # User model object
exclude_user_id: Optional[int] = None,
admin_only: bool = False
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Подписи формируются динамически в зависимости от получателя:
- Админы видят: nickname (карта: XXXX)
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args:
message: Сообщение для рассылки
sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
Возвращает: (forwarded_ids, success_count, fail_count)
"""
async with async_session_maker() as session:
users = await get_all_active_users(session)
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
forwarded_ids = {}
success_count = 0
fail_count = 0
# Разбиваем на пакеты
for i in range(0, len(users), BATCH_SIZE):
batch = users[i:i + BATCH_SIZE]
# Отправляем пакет
tasks = []
for recipient_user in batch:
# Формируем подпись в зависимости от получателя
if recipient_user.telegram_id in ADMIN_IDS:
# Админы видят полную информацию: nickname (карта: XXXX)
sender_name = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
if sender_user.club_card_number:
sender_name += f" (карта: {sender_user.club_card_number})"
sender_info = sender_name
tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
else:
# Обычные пользователи видят:
# - "Админ" если отправитель - админ
# - nickname если отправитель - обычный пользователь
if sender_user.telegram_id in ADMIN_IDS:
sender_info = "Админ"
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
else:
sender_info = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for user, result in zip(batch, results):
if isinstance(result, Exception):
fail_count += 1
elif result is not None:
forwarded_ids[str(user.telegram_id)] = result
success_count += 1
else:
fail_count += 1
# Задержка между пакетами (если есть еще пакеты)
if i + BATCH_SIZE < len(users):
await asyncio.sleep(BATCH_DELAY)
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
user_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
user_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
user_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
user_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
user_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
user_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
admin_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
admin_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
admin_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
# Пересылаем сообщение в канал
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
return False, None
@router.message(F.text, StateFilter(ChatStates.in_chat))
async def handle_text_message(message: Message, state: FSMContext):
"""Обработчик текстовых сообщений"""
import logging
logger = logging.getLogger(__name__)
# Защита от дубликатов - если сообщение уже обработано, пропускаем # Защита от дубликатов - если сообщение уже обработано, пропускаем
if _is_message_processed(message.message_id): if _is_message_processed(message.message_id):
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем") logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
return return
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}") logger.info(f"[CHAT] check_exit_keywords вызван для обработки: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем # ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
# Пропускаем для account_router (который идет после chat_router) # Пропускаем для account_router (который идет после chat_router)
@@ -657,6 +362,305 @@ async def handle_text_message(message: Message, state: FSMContext):
await message.answer("Не удалось переслать сообщение") await message.answer("Не удалось переслать сообщение")
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
# Защита от дубликатов сообщений (храним последние 100 message_id)
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
return True
_processed_messages.append(message_id)
return False
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
users = await UserService.get_all_users(session)
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным
# Они все имеют право общаться в чате (главное - что они вошли в чат)
return users
async def broadcast_message_with_scheduler(
message: Message,
sender_user: Any, # User model object
exclude_user_id: Optional[int] = None,
admin_only: bool = False
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Подписи формируются динамически в зависимости от получателя:
- Админы видят: nickname (карта: XXXX)
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args:
message: Сообщение для рассылки
sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
Возвращает: (forwarded_ids, success_count, fail_count)
"""
import logging
logger = logging.getLogger(__name__)
async with async_session_maker() as session:
users = await get_all_active_users(session)
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
forwarded_ids = {}
success_count = 0
fail_count = 0
# Разбиваем на пакеты
for i in range(0, len(users), BATCH_SIZE):
batch = users[i:i + BATCH_SIZE]
# Отправляем пакет
tasks = []
for recipient_user in batch:
# Формируем подпись в зависимости от получателя
if recipient_user.telegram_id in ADMIN_IDS:
# Админы видят полную информацию: nickname (карта: XXXX)
sender_name = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
if sender_user.club_card_number:
sender_name += f" (карта: {sender_user.club_card_number})"
sender_info = sender_name
tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
else:
# Обычные пользователи видят:
# - "Админ" если отправитель - админ
# - nickname если отправитель - обычный пользователь
if sender_user.telegram_id in ADMIN_IDS:
sender_info = "Админ"
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
else:
sender_info = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for user, result in zip(batch, results):
if isinstance(result, Exception):
fail_count += 1
elif result is not None:
forwarded_ids[str(user.telegram_id)] = result
success_count += 1
else:
fail_count += 1
# Задержка между пакетами (если есть еще пакеты)
if i + BATCH_SIZE < len(users):
await asyncio.sleep(BATCH_DELAY)
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
user_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
user_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
user_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
user_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
user_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
user_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
admin_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
admin_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
admin_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
# Пересылаем сообщение в канал
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
return False, None
@router.message(F.photo, StateFilter(ChatStates.in_chat)) @router.message(F.photo, StateFilter(ChatStates.in_chat))
async def handle_photo_message(message: Message, state: FSMContext): async def handle_photo_message(message: Message, state: FSMContext):
"""Обработчик фото""" """Обработчик фото"""

View File

@@ -150,24 +150,36 @@ async def help_logins(callback: CallbackQuery):
"В этом разделе вы всегда сможете найти свои добавленные логины в розыгрыши, " "В этом разделе вы всегда сможете найти свои добавленные логины в розыгрыши, "
"которые администратор указал для вас в системе.\n\n" "которые администратор указал для вас в системе.\n\n"
"⚠️ <b>Важное уточнение:</b>\n\n" "<b>Какая информация показывается?</b>\n\n"
"Имейте в виду, что логины, которые не отыграны по условиям розыгрыша, " "Для каждого логина вы сможете увидеть:\n"
"<b>не добавляются в список</b>. В списке отображаются только активные логины, " "🎲 <b>Активные розыгрыши</b> - розыгрыши в которых сейчас участвует логин\n"
"которые соответствуют условиям текущих и будущих розыгрышей.\n\n" "🏁 <b>Завершенные розыгрыши</b> - прошедшие розыгрыши:\n"
" 🏆 ВЫИГРАЛ - если логин победил (указано место)\n"
"Не выиграл - если логин не получил приз\n\n"
"⚠️ <b>Важное уточнение о статусе логинов:</b>\n\n"
"✅ <b>Зеленый (активный)</b> - логин участвует в новых розыгрышах\n"
"⏸️ <b>Серый (неактивный)</b> - логин не участвует в новых розыгрышах\n\n"
"Имейте в виду, что логины, которые участвовали в закрытых розыгрышах, "
"<b>не добавляются в новые розыгрыши</b>. В списке отображаются только те логины, "
"которые активны и соответствуют условиям текущих розыгрышей.\n\n"
"<b>Как это работает:</b>\n\n"
"1⃣ Если у вас есть 100 логинов\n"
"2⃣ 60 из них участвовали в прошедших/закрытых розыгрышах\n"
"3⃣ Статус этих 60 логинов будет ⏸️ (неактивны)\n"
"4⃣ Они не добавляются в новые розыгрыши\n"
"5В новых розыгрышах участвуют только оставшиеся активные логины\n\n"
"<b>Как использовать:</b>\n\n" "<b>Как использовать:</b>\n\n"
"1⃣ Откройте главное меню\n" "1⃣ Откройте главное меню\n"
"2⃣ Нажмите кнопку <i>\"Мои логины\"</i>\n" "2⃣ Нажмите кнопку <i>\"Мои логины\"</i>\n"
"3⃣ Вы увидите список всех ваших активных логинов\n\n" "3⃣ Вы увидите полный список всех ваших логинов с информацией\n\n"
"📋 <b>Что показывается:</b>\n\n"
" ✅ Активные логины, добавленные администратором\n"
" ✅ Логины, соответствующие условиям розыгрышей\n"
" ❌ Неотыгранные логины (в них не проводятся розыгрыши)\n\n"
"💡 <b>Совет:</b>\n" "💡 <b>Совет:</b>\n"
"Если вы не видите ожидаемый логин в списке, это значит, что он не соответствует " "Если вы не видите ожидаемый логин в списке активных розыгрышей, "
"условиям текущих розыгрышей или администратор еще не добавил его в систему. " "это может означать, что он уже участвовал в закрытых розыгрышах и помечен как неактивный. "
"Свяжитесь с администратором для уточнения.\n\n" "Свяжитесь с администратором для уточнения.\n\n"
"🔄 <b>Обновление информации:</b>\n" "🔄 <b>Обновление информации:</b>\n"

View File

@@ -30,6 +30,35 @@ def is_admin(user_id: int) -> bool:
return user_id in ADMIN_IDS return user_id in ADMIN_IDS
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
"""
Форматирует имя отправителя для отображения в чате
Args:
user: Объект пользователя
is_current_user: Текущий ли это пользователь
current_user_is_admin: Админ ли текущий пользователь
Returns:
Отформатированное имя
"""
if is_current_user:
return "🔵 Вы"
# Если это администратор и текущий пользователь не админ - показываем "Админ"
if user.is_admin and not current_user_is_admin:
return "🔵 Админ"
# Формируем базовое имя (используем nickname из профиля)
name = user.nickname or user.first_name or f"@{user.username}" or "Unknown"
# Добавляем информацию о карте если пользователь админ и текущий юзер админ
if current_user_is_admin and user.club_card_number:
name += f" (карта: {user.club_card_number})"
return f"🔵 {name}"
@router.message(CaseInsensitiveCommand("chat")) @router.message(CaseInsensitiveCommand("chat"))
async def show_chat_menu(message: Message, state: FSMContext): async def show_chat_menu(message: Message, state: FSMContext):
""" """
@@ -106,7 +135,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
# Создаём кнопки с пользователями (по 1 на строку) # Создаём кнопки с пользователями (по 1 на строку)
buttons = [] buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице for user in users[:20]: # Ограничение 20 пользователей на странице
display_name = f"@{user.username}" if user.username else user.first_name display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
if user.club_card_number: if user.club_card_number:
display_name += f" (карта: {user.club_card_number})" display_name += f" (карта: {user.club_card_number})"
@@ -162,14 +191,18 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id) await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
await state.set_state(P2PChatStates.chatting) await state.set_state(P2PChatStates.chatting)
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name recipient_name = recipient.nickname or f"@{recipient.username}" or recipient.first_name or "Unknown"
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n" text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
if messages: if messages:
text += "📝 <b>Последние сообщения:</b>\n\n" text += "📝 <b>Последние сообщения:</b>\n\n"
for msg in reversed(messages[-5:]): # Последние 5 сообщений for msg in reversed(messages[-5:]): # Последние 5 сообщений
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name # Определяем имя отправителя
is_current = msg.sender_id == sender.id
user_for_display = sender if is_current else recipient
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]") 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 += f"{sender_name}: {msg_text}\n"
text += "\n" text += "\n"
@@ -204,7 +237,7 @@ async def show_conversations(callback: CallbackQuery):
last_name=callback.from_user.last_name last_name=callback.from_user.last_name
) )
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10) conversations = await P2PMessageService.get_recent_conversations(session, sender.id, limit=10)
if not conversations: if not conversations:
await callback.message.edit_text( await callback.message.edit_text(
@@ -217,7 +250,7 @@ async def show_conversations(callback: CallbackQuery):
buttons = [] buttons = []
for peer, last_msg, unread in conversations: for peer, last_msg, unread in conversations:
peer_name = f"@{peer.username}" if peer.username else peer.first_name peer_name = peer.nickname or f"@{peer.username}" or peer.first_name or "Unknown"
# Иконка в зависимости от непрочитанных # Иконка в зависимости от непрочитанных
icon = "🔴" if unread > 0 else "💬" icon = "🔴" if unread > 0 else "💬"
@@ -234,7 +267,11 @@ async def show_conversations(callback: CallbackQuery):
callback_data=f"p2p:user:{peer.id}" callback_data=f"p2p:user:{peer.id}"
)]) )])
text += f"{icon} <b>{peer_name}</b>\n" text += f"{icon} <b>{peer_name}</b>"
# Показываем номер карты если есть
if peer.club_card_number:
text += f" (карта: {peer.club_card_number})"
text += "\n"
text += f" {preview}\n" text += f" {preview}\n"
if unread > 0: if unread > 0:
text += f" 📨 Непрочитанных: {unread}\n" text += f" 📨 Непрочитанных: {unread}\n"
@@ -268,12 +305,53 @@ async def end_conversation(callback: CallbackQuery, state: FSMContext):
async def back_to_menu(callback: CallbackQuery, state: FSMContext): async def back_to_menu(callback: CallbackQuery, state: FSMContext):
"""Вернуться в главное меню""" """Вернуться в главное меню"""
await callback.answer() await callback.answer()
await state.clear()
# Имитируем команду /chat async with async_session_maker() as session:
fake_message = callback.message user = await UserService.get_or_create_user(
fake_message.from_user = callback.from_user session,
callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name
)
if not user:
await callback.message.edit_text("❌ Вы не зарегистрированы. Используйте /start")
return
# Получаем количество непрочитанных сообщений
unread_count = await P2PMessageService.get_unread_count(session, user.id)
# Получаем последние диалоги
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
await show_chat_menu(fake_message, state) 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 recent:
text += "\n\n<b>Последние диалоги:</b>\n"
for peer, last_msg, unread in recent:
unread_badge = f" ({unread})" if unread > 0 else ""
text += f" • @{peer.username or peer.first_name}{unread_badge}\n"
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
# Обработчик сообщений в состоянии chatting # Обработчик сообщений в состоянии chatting
@@ -301,7 +379,19 @@ async def handle_p2p_message(message: Message, state: FSMContext):
first_name=message.from_user.first_name, first_name=message.from_user.first_name,
last_name=message.from_user.last_name last_name=message.from_user.last_name
) )
sender_name = f"@{sender.username}" if sender.username else sender.first_name
# Получаем информацию о получателе для определения как подписать сообщение
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
# Формируем подпись сообщения для получателя
if sender.is_admin:
sender_name = "АДМИН"
else:
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
# Добавляем карту если получатель админ
if recipient and recipient.is_admin and sender.club_card_number:
sender_name += f" (карта: {sender.club_card_number})"
# Определяем тип сообщения # Определяем тип сообщения
message_type = "text" message_type = "text"
@@ -326,28 +416,28 @@ async def handle_p2p_message(message: Message, state: FSMContext):
if message_type == "text": if message_type == "text":
sent = await message.bot.send_message( sent = await message.bot.send_message(
recipient_telegram_id, recipient_telegram_id,
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}", f"<b>{sender_name}</b>\n\n{text}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "photo": elif message_type == "photo":
sent = await message.bot.send_photo( sent = await message.bot.send_photo(
recipient_telegram_id, recipient_telegram_id,
photo=file_id, photo=file_id,
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" , caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "video": elif message_type == "video":
sent = await message.bot.send_video( sent = await message.bot.send_video(
recipient_telegram_id, recipient_telegram_id,
video=file_id, video=file_id,
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "document": elif message_type == "document":
sent = await message.bot.send_document( sent = await message.bot.send_document(
recipient_telegram_id, recipient_telegram_id,
document=file_id, document=file_id,
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )

View File

@@ -2,6 +2,8 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.filters.case_insensitive import CaseInsensitiveCommand from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -11,6 +13,7 @@ import logging
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.registration_services import RegistrationService, AccountService from src.core.registration_services import RegistrationService, AccountService
from src.core.services import UserService from src.core.services import UserService
from src.core.models import Participation, Winner, Lottery
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() router = Router()
@@ -181,12 +184,12 @@ async def process_phone(message: Message, state: FSMContext):
"✅ Регистрация завершена!\n\n" "✅ Регистрация завершена!\n\n"
f"🎭 Никнейм: {user.nickname}\n" f"🎭 Никнейм: {user.nickname}\n"
f"🎫 Клубная карта: {user.club_card_number}\n" f"🎫 Клубная карта: {user.club_card_number}\n"
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n" f"🔑 Ваш код верификации: <b>{user.verification_code}</b>\n\n"
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n" "⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
"Теперь вы можете участвовать в розыгрышах!" "Теперь вы можете участвовать в розыгрышах!"
) )
await message.answer(text, parse_mode="Markdown") await message.answer(text, parse_mode="HTML")
await state.clear() await state.clear()
except ValueError as e: except ValueError as e:
@@ -212,17 +215,17 @@ async def show_verification_code(message: Message):
text = ( text = (
"🔑 Ваш код верификации:\n\n" "🔑 Ваш код верификации:\n\n"
f"**{user.verification_code}**\n\n" f"<code>{user.verification_code}</code>\n\n"
"Этот код используется для подтверждения выигрыша.\n" "Этот код используется для подтверждения выигрыша.\n"
"Сообщите его администратору при получении приза." "Сообщите его администратору при получении приза."
) )
await message.answer(text, parse_mode="Markdown") await message.answer(text, parse_mode="HTML")
@router.message(Command("my_accounts")) @router.message(Command("my_accounts"))
async def show_user_accounts(message: Message): async def show_user_accounts(message: Message):
"""Показать счета пользователя""" """Показать логины пользователя с информацией о розыгрышах"""
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id) user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
@@ -234,15 +237,69 @@ async def show_user_accounts(message: Message):
if not accounts: if not accounts:
await message.answer( await message.answer(
"У вас пока нет привязанных счетов.\n\n" "У вас пока нет привязанных логинов.\n\n"
"Счета добавляются администратором." "Логины добавляются администратором."
) )
return return
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n" text = f"📱 <b>Ваши логины</b> (Клубная карта: {user.club_card_number})\n\n"
for i, account in enumerate(accounts, 1): for i, account in enumerate(accounts, 1):
status = "" if account.is_active else "" # Получаем participations для этого account с загруженными данными о lottery
text += f"{i}. {status} {account.account_number}\n" participations = await session.execute(
select(Participation)
.where(Participation.account_id == account.id)
.options(selectinload(Participation.lottery))
)
participations = participations.scalars().all()
# Определяем статус логина
active_participations = [p for p in participations if not p.lottery.is_completed]
closed_participations = [p for p in participations if p.lottery.is_completed]
# Основная информация о логине
status_icon = "" if account.is_active and active_participations else "⏸️"
text += f"{i}. {status_icon} <b>{account.account_number}</b>\n"
if active_participations:
text += " 🎲 <b>Активные розыгрыши:</b>\n"
for p in active_participations[:5]: # Показываем не более 5
status = "🟢"
text += f" {status} {p.lottery.title}\n"
if len(active_participations) > 5:
text += f" ... и еще {len(active_participations) - 5}\n"
if closed_participations:
text += " 🏁 <b>Завершенные розыгрыши:</b>\n"
for p in closed_participations[:3]: # Показываем не более 3
# Проверяем, выиграл ли в этом розыгрыше
winner_result = await session.execute(
select(Winner)
.where(
(Winner.lottery_id == p.lottery_id) &
(Winner.account_number == account.account_number)
)
)
winner = winner_result.scalar_one_or_none()
if winner:
text += f" 🏆 {p.lottery.title} - <b>ВЫИГРАЛ!</b> ({winner.place} место)\n"
else:
text += f"{p.lottery.title}\n"
if len(closed_participations) > 3:
text += f" ... и еще {len(closed_participations) - 3} закрытых\n"
if not participations:
text += " В розыгрышах не участвовал\n"
text += "\n"
await message.answer(text) # Добавляем примечание о неактивных логинах
if any(not acc.is_active for acc in accounts):
text += "⏸️ - Логин не участвует в новых розыгрышах\n"
text += "✅ - Логин активен и может участвовать\n\n"
text += "💡 <b>Заметка:</b> Логины, участвовавшие в закрытых розыгрышах, не добавляются в новые розыгрыши."
await message.answer(text, parse_mode="HTML")

101
test_chat_fix.md Normal file
View File

@@ -0,0 +1,101 @@
# Исправление функции чата
## 🔴 Проблема
При переходе в чат, сообщения не отправлялись другим участникам. Пользователи не получали сообщения друг от друга.
## 🔍 Корневые причины (найдено ДВЕ)
### Причина 1: Неправильная фильтрация активных пользователей
В функции `get_all_active_users()` (строка 189-192) рассылка осуществлялась только:
- Зарегистрированным пользователям (`u.is_registered == True`)
- ИЛИ админам
Это означало, что обычные пользователи, не прошедшие полную регистрацию, не получали сообщения в чате.
**Статус в БД:** Было 7 пользователей, из них только 2 зарегистрированы, остальные 5 не получали сообщения.
### Причина 2: Дублирующиеся обработчики текстовых сообщений
В файле `src/handlers/chat_handlers.py` было ДВА обработчика для текстовых сообщений в состоянии `ChatStates.in_chat`:
1. **`check_exit_keywords()` (строка 140)**:
- Декоратор: `@router.message(StateFilter(ChatStates.in_chat), F.text)`
- Функция: проверяла ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
- **ПРОБЛЕМА**: если сообщение не было ключевым словом, функция просто заканчивалась без `return`, но это НЕ означало, что выполнение продолжится в следующем обработчике. Aiogram использует первый подходящий обработчик, и второй никогда не вызывался.
2. **`handle_text_message()` (строка 663)** - дублирующий обработчик:
- Декоратор: `@router.message(F.text, StateFilter(ChatStates.in_chat))`
- Функция: содержала вся логика для рассылки сообщений
- **ПРОБЛЕМА**: эта функция НИКОГДА не вызывалась, потому что первый обработчик `check_exit_keywords()` перехватывал все текстовые сообщения.
## ✅ Сделанные исправления
### Исправление 1: Изменена логика получения активных пользователей
```python
# ДО (неправильно):
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
users = await UserService.get_all_users(session)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
# ПОСЛЕ (правильно):
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
users = await UserService.get_all_users(session)
return users
```
### Исправление 2: Объединены дублирующиеся обработчики
- **Объединена вся логика обработки сообщений в `check_exit_keywords()`** (теперь переименована концептуально, но осталась в коде)
- **Удален дублирующий обработчик `handle_text_message()`**
- Новая логика:
1. Проверяются ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
2. **Если это не ключевое слово** → продолжается обработка как обычного сообщения чата
3. Выполняется полная логика рассылки/пересылки
### Исправление 3: Добавлено логирование для отладки
Добавлены логи в `broadcast_message_with_scheduler()`:
```python
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
```
## 📊 Измененные файлы
- **src/handlers/chat_handlers.py**:
- Строка 189-192: Функция `get_all_active_users()` теперь возвращает **всех** пользователей
- Строка 140-358: Объединена вся логика обработки текстовых сообщений в функцию `check_exit_keywords()`
- Строка 663-857: **Удален** дублирующий обработчик `handle_text_message()`
## 🧪 Тестирование
### Инструкции для тестирования:
1. **Убедитесь, что есть минимум 2 пользователя в системе** (заказывали с 7 пользователями)
2. **Первый пользователь**: отправляет `/chat` или нажимает "Войти в чат"
3. **Второй пользователь**: отправляет `/chat` или нажимает "Войти в чат"
4. **Первый пользователь**: отправляет текстовое сообщение в чат
5. **Второй пользователь**: должен **получить сообщение** с заголовком типа:
- Для админов: `📨 Сообщение от [nickname] (карта: XXXX):`
- Для обычных пользователей: `📨 [nickname]:`
### Проверка логов:
```bash
docker compose logs -f bot | grep "\[CHAT\]"
```
Должны быть строки:
- `[CHAT] check_exit_keywords вызван для обработки: user=...`
- `[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: N`
- `[CHAT] После исключения отправителя: N пользователей`
- `[CHAT] broadcast_message_with_scheduler завершена: успешно=N, ошибок=M`
## 🎯 Ожидаемый результат
После применения этого исправления:
Все пользователи будут получать сообщения в чате
✅ Сообщения будут рассылаться **независимо от статуса регистрации**
✅ Логирование позволит отследить проблемы при возникновении
✅ Система корректно проверяет ключевые слова для выхода из чата
✅ Сообщения рассылаются **всем** пользователям, включая незарегистрированных