Compare commits
9 Commits
v2_functio
...
06ddd1e5fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 06ddd1e5fa | |||
| 815cc544d5 | |||
| 2db39b0652 | |||
| 4160d69fa7 | |||
| 6b2e915452 | |||
| 8eca76b844 | |||
| fe23306adb | |||
| 388c4e8aad | |||
| 4b06cd2f9e |
@@ -5,14 +5,14 @@
|
||||
BOT_TOKEN=8125171867:AAHA0l2hGGodOUBh0rFlkE4CxK0X6JzZv64
|
||||
|
||||
# PostgreSQL настройки для Docker контейнера
|
||||
POSTGRES_HOST=192.168.0.102
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=lottery_bot
|
||||
POSTGRES_USER=trevor
|
||||
POSTGRES_USER=lottery_user
|
||||
POSTGRES_PASSWORD=Cl0ud_1985!
|
||||
|
||||
# Database URL для бота (использует postgres как hostname внутри Docker сети)
|
||||
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot
|
||||
DATABASE_URL=postgresql+asyncpg://lottery_user:Cl0ud_1985!@postgres:5432/lottery_bot
|
||||
# Redis URL
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
|
||||
65
CHAT_FIX_REPORT.md
Normal file
65
CHAT_FIX_REPORT.md
Normal 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` (документация об исправлении)
|
||||
@@ -93,12 +93,10 @@ if not owner or owner.telegram_id != callback.from_user.id:
|
||||
### Что НЕ может сделать пользователь:
|
||||
|
||||
❌ Подтвердить чужой счет
|
||||
❌ Подтвердить счет, который ему не принадлежит
|
||||
❌ Подтвердить один счет дважды
|
||||
|
||||
### Что может сделать пользователь:
|
||||
|
||||
✅ Подтвердить только свои счета
|
||||
✅ Подтвердить каждый свой выигрышный счет отдельно
|
||||
✅ Видеть номер счета на каждой кнопке
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
# Система управления кастомными эмодзи
|
||||
|
||||
## Обзор
|
||||
|
||||
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
|
||||
|
||||
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()` перед отправкой
|
||||
9
export_20260208_174031.json
Normal file
9
export_20260208_174031.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:40:31.898764",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
9
export_20260208_174208.json
Normal file
9
export_20260208_174208.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:42:08.014799",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
9
export_20260208_174221.json
Normal file
9
export_20260208_174221.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:42:21.844218",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
43
main.py
43
main.py
@@ -30,7 +30,6 @@ from src.handlers.account_handlers import account_router
|
||||
from src.handlers.message_management import message_admin_router
|
||||
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||
from src.handlers.help_handlers import router as help_router
|
||||
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -135,41 +134,18 @@ async def btn_chat(message: Message, state: FSMContext):
|
||||
@router.message(F.text == "📝 Регистрация")
|
||||
async def btn_registration(message: Message, state: FSMContext):
|
||||
"""Обработчик кнопки 'Регистрация'"""
|
||||
from src.handlers.registration_handlers import RegistrationStates
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
logger.info(f"User {message.from_user.id} pressed Registration button")
|
||||
|
||||
text = (
|
||||
"📝 Регистрация в системе\n\n"
|
||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
||||
"🎭 Введите ваш никнейм для чата:\n"
|
||||
"• От 2 до 20 символов\n"
|
||||
"• Может содержать буквы, цифры, пробелы\n"
|
||||
"• Это имя будут видеть другие участники"
|
||||
fake_callback = CallbackQuery(
|
||||
id="fake",
|
||||
from_user=message.from_user,
|
||||
chat_instance="0",
|
||||
data="start_registration",
|
||||
message=message
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("register"))
|
||||
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)
|
||||
from src.handlers.registration_handlers import start_registration
|
||||
await start_registration(fake_callback, state)
|
||||
|
||||
|
||||
@router.message(F.text == "🔑 Мой код")
|
||||
@@ -290,7 +266,6 @@ async def main():
|
||||
|
||||
# 2. Специфичные роутеры
|
||||
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
||||
dp.include_router(admin_emoji_router) # Управление кастомными эмодзи
|
||||
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
|
||||
dp.include_router(registration_router) # Регистрация
|
||||
dp.include_router(admin_account_router) # Админские команды счетов
|
||||
|
||||
28
migrations/versions/20260213_1812_12_41aae82e631b_.py
Normal file
28
migrations/versions/20260213_1812_12_41aae82e631b_.py
Normal 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 ###
|
||||
@@ -1,7 +1,7 @@
|
||||
"""merge branches
|
||||
|
||||
Revision ID: merge_migration
|
||||
Revises: cd31303a681c
|
||||
Revises: 41aae82e631b, cd31303a681c
|
||||
Create Date: 2026-02-18 04:02:12.000000
|
||||
|
||||
"""
|
||||
@@ -11,7 +11,7 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'merge_migration'
|
||||
down_revision = 'cd31303a681c'
|
||||
down_revision = ('41aae82e631b', 'cd31303a681c')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -35,9 +35,6 @@ class BotController(IBotController):
|
||||
async def handle_start(self, message: Message):
|
||||
"""Обработать команду /start"""
|
||||
from src.utils.keyboards import get_main_reply_keyboard
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
user = await self.user_service.get_or_create_user(
|
||||
telegram_id=message.from_user.id,
|
||||
@@ -46,9 +43,6 @@ class BotController(IBotController):
|
||||
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 += "🎲 Это бот для участия в розыгрышах.\n\n"
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Сервис для управления маппингом кастомных эмодзи"""
|
||||
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
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
Утилиты для автоматической замены эмодзи на 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)
|
||||
@@ -313,25 +313,3 @@ class BroadcastLog(Base):
|
||||
|
||||
def __repr__(self):
|
||||
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]}...)>"
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""
|
||||
Хендлеры для управления кастомными эмодзи админом
|
||||
Админ отправляет эмодзи боту, бот сохраняет 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)
|
||||
@@ -69,6 +69,10 @@ class AdminStates(StatesGroup):
|
||||
remove_participant_bulk_accounts = State()
|
||||
participant_search = State()
|
||||
|
||||
# Добавление/удаление участников в конкретном розыгрыше
|
||||
add_to_lottery_user = State()
|
||||
remove_from_lottery_user = State()
|
||||
|
||||
# Установка победителей
|
||||
set_winner_lottery = State()
|
||||
set_winner_place = State()
|
||||
@@ -602,6 +606,7 @@ async def show_lottery_detail(callback: CallbackQuery):
|
||||
buttons = []
|
||||
|
||||
if not lottery.is_completed:
|
||||
# Розыгрыш ещё не проведён
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="🏆 Установить победителя", callback_data=f"admin_set_winner_{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([
|
||||
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_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_system_info")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
|
||||
@@ -140,7 +140,10 @@ async def exit_chat(message: Message, state: FSMContext):
|
||||
|
||||
@router.message(StateFilter(ChatStates.in_chat), F.text)
|
||||
async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
"""Проверка на ключевые слова для выхода из чата"""
|
||||
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
text = message.text.strip().lower()
|
||||
|
||||
# Проверяем ключевые слова для выхода
|
||||
@@ -166,311 +169,13 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
await exit_chat(message, state)
|
||||
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):
|
||||
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
||||
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)
|
||||
@@ -657,6 +362,305 @@ async def handle_text_message(message: Message, state: FSMContext):
|
||||
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))
|
||||
async def handle_photo_message(message: Message, state: FSMContext):
|
||||
"""Обработчик фото"""
|
||||
|
||||
@@ -20,7 +20,6 @@ def get_help_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="📝 Регистрация", callback_data="help_registration")],
|
||||
[InlineKeyboardButton(text="🎰 Участие в розыгрышах", callback_data="help_lottery")],
|
||||
[InlineKeyboardButton(text="📱 Мои логины", callback_data="help_logins")],
|
||||
[InlineKeyboardButton(text="💬 Чат", callback_data="help_chat")],
|
||||
[InlineKeyboardButton(text="⚙️ Команды", callback_data="help_commands")],
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
@@ -57,7 +56,6 @@ async def show_help_main(message: Message, edit: bool = False):
|
||||
"Выберите интересующий вас раздел:\n\n"
|
||||
"📝 <b>Регистрация</b> - как зарегистрироваться в системе\n"
|
||||
"🎰 <b>Участие в розыгрышах</b> - как участвовать и выигрывать\n"
|
||||
"📱 <b>Мои логины</b> - информация о ваших добавленных логинах\n"
|
||||
"💬 <b>Чат</b> - общение с другими участниками\n"
|
||||
"⚙️ <b>Команды</b> - список доступных команд"
|
||||
)
|
||||
@@ -139,61 +137,6 @@ async def help_lottery(callback: CallbackQuery):
|
||||
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "help_logins")
|
||||
async def help_logins(callback: CallbackQuery):
|
||||
"""Справка по логинам пользователей"""
|
||||
await callback.answer()
|
||||
|
||||
text = (
|
||||
"📱 <b>Мои логины</b>\n\n"
|
||||
"<b>Что это такое?</b>\n\n"
|
||||
"В этом разделе вы всегда сможете найти свои добавленные логины в розыгрыши, "
|
||||
"которые администратор указал для вас в системе.\n\n"
|
||||
|
||||
"<b>Какая информация показывается?</b>\n\n"
|
||||
"Для каждого логина вы сможете увидеть:\n"
|
||||
"🎲 <b>Активные розыгрыши</b> - розыгрыши в которых сейчас участвует логин\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"
|
||||
"1️⃣ Откройте главное меню\n"
|
||||
"2️⃣ Нажмите кнопку <i>\"Мои логины\"</i>\n"
|
||||
"3️⃣ Вы увидите полный список всех ваших логинов с информацией\n\n"
|
||||
|
||||
"💡 <b>Совет:</b>\n"
|
||||
"Если вы не видите ожидаемый логин в списке активных розыгрышей, "
|
||||
"это может означать, что он уже участвовал в закрытых розыгрышах и помечен как неактивный. "
|
||||
"Свяжитесь с администратором для уточнения.\n\n"
|
||||
|
||||
"🔄 <b>Обновление информации:</b>\n"
|
||||
"Список обновляется автоматически при каждом открытии раздела."
|
||||
)
|
||||
|
||||
keyboard = get_back_to_help_keyboard()
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
except:
|
||||
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "help_chat")
|
||||
async def help_chat(callback: CallbackQuery):
|
||||
"""Справка по чату"""
|
||||
|
||||
@@ -30,35 +30,6 @@ def is_admin(user_id: int) -> bool:
|
||||
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"))
|
||||
async def show_chat_menu(message: Message, state: FSMContext):
|
||||
"""
|
||||
@@ -135,7 +106,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
# Создаём кнопки с пользователями (по 1 на строку)
|
||||
buttons = []
|
||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||
display_name = f"@{user.username}" if user.username else user.first_name
|
||||
if user.club_card_number:
|
||||
display_name += f" (карта: {user.club_card_number})"
|
||||
|
||||
@@ -191,18 +162,14 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||||
await state.set_state(P2PChatStates.chatting)
|
||||
|
||||
recipient_name = recipient.nickname or f"@{recipient.username}" or recipient.first_name or "Unknown"
|
||||
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
|
||||
|
||||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||
|
||||
if messages:
|
||||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||
# Определяем имя отправителя
|
||||
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))
|
||||
|
||||
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||
text += f"• {sender_name}: {msg_text}\n"
|
||||
text += "\n"
|
||||
@@ -237,7 +204,7 @@ async def show_conversations(callback: CallbackQuery):
|
||||
last_name=callback.from_user.last_name
|
||||
)
|
||||
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, sender.id, limit=10)
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
|
||||
|
||||
if not conversations:
|
||||
await callback.message.edit_text(
|
||||
@@ -250,7 +217,7 @@ async def show_conversations(callback: CallbackQuery):
|
||||
|
||||
buttons = []
|
||||
for peer, last_msg, unread in conversations:
|
||||
peer_name = peer.nickname or f"@{peer.username}" or peer.first_name or "Unknown"
|
||||
peer_name = f"@{peer.username}" if peer.username else peer.first_name
|
||||
|
||||
# Иконка в зависимости от непрочитанных
|
||||
icon = "🔴" if unread > 0 else "💬"
|
||||
@@ -267,11 +234,7 @@ async def show_conversations(callback: CallbackQuery):
|
||||
callback_data=f"p2p:user:{peer.id}"
|
||||
)])
|
||||
|
||||
text += f"{icon} <b>{peer_name}</b>"
|
||||
# Показываем номер карты если есть
|
||||
if peer.club_card_number:
|
||||
text += f" (карта: {peer.club_card_number})"
|
||||
text += "\n"
|
||||
text += f"{icon} <b>{peer_name}</b>\n"
|
||||
text += f" {preview}\n"
|
||||
if unread > 0:
|
||||
text += f" 📨 Непрочитанных: {unread}\n"
|
||||
@@ -305,53 +268,12 @@ async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||||
"""Вернуться в главное меню"""
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_or_create_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
|
||||
)
|
||||
# Имитируем команду /chat
|
||||
fake_message = callback.message
|
||||
fake_message.from_user = callback.from_user
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
await show_chat_menu(fake_message, state)
|
||||
|
||||
|
||||
# Обработчик сообщений в состоянии chatting
|
||||
@@ -379,19 +301,7 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_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})"
|
||||
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||
|
||||
# Определяем тип сообщения
|
||||
message_type = "text"
|
||||
@@ -416,28 +326,28 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
if message_type == "text":
|
||||
sent = await message.bot.send_message(
|
||||
recipient_telegram_id,
|
||||
f"<b>{sender_name}</b>\n\n{text}",
|
||||
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "photo":
|
||||
sent = await message.bot.send_photo(
|
||||
recipient_telegram_id,
|
||||
photo=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
|
||||
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" ,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "video":
|
||||
sent = await message.bot.send_video(
|
||||
recipient_telegram_id,
|
||||
video=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "document":
|
||||
sent = await message.bot.send_document(
|
||||
recipient_telegram_id,
|
||||
document=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -13,7 +11,6 @@ import logging
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import RegistrationService, AccountService
|
||||
from src.core.services import UserService
|
||||
from src.core.models import Participation, Winner, Lottery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
@@ -184,12 +181,12 @@ async def process_phone(message: Message, state: FSMContext):
|
||||
"✅ Регистрация завершена!\n\n"
|
||||
f"🎭 Никнейм: {user.nickname}\n"
|
||||
f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
f"🔑 Ваш код верификации: <b>{user.verification_code}</b>\n\n"
|
||||
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
|
||||
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
||||
"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
await state.clear()
|
||||
|
||||
except ValueError as e:
|
||||
@@ -215,17 +212,17 @@ async def show_verification_code(message: Message):
|
||||
|
||||
text = (
|
||||
"🔑 Ваш код верификации:\n\n"
|
||||
f"<code>{user.verification_code}</code>\n\n"
|
||||
f"**{user.verification_code}**\n\n"
|
||||
"Этот код используется для подтверждения выигрыша.\n"
|
||||
"Сообщите его администратору при получении приза."
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
|
||||
@router.message(Command("my_accounts"))
|
||||
async def show_user_accounts(message: Message):
|
||||
"""Показать логины пользователя с информацией о розыгрышах"""
|
||||
"""Показать счета пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
@@ -237,69 +234,15 @@ async def show_user_accounts(message: Message):
|
||||
|
||||
if not accounts:
|
||||
await message.answer(
|
||||
"У вас пока нет привязанных логинов.\n\n"
|
||||
"Логины добавляются администратором."
|
||||
"У вас пока нет привязанных счетов.\n\n"
|
||||
"Счета добавляются администратором."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"📱 <b>Ваши логины</b> (Клубная карта: {user.club_card_number})\n\n"
|
||||
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
|
||||
|
||||
for i, account in enumerate(accounts, 1):
|
||||
# Получаем participations для этого account с загруженными данными о lottery
|
||||
participations = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.account_id == account.id)
|
||||
.options(selectinload(Participation.lottery))
|
||||
)
|
||||
participations = participations.scalars().all()
|
||||
status = "✅" if account.is_active else "❌"
|
||||
text += f"{i}. {status} {account.account_number}\n"
|
||||
|
||||
# Определяем статус логина
|
||||
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"
|
||||
|
||||
# Добавляем примечание о неактивных логинах
|
||||
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")
|
||||
await message.answer(text)
|
||||
|
||||
101
test_chat_fix.md
Normal file
101
test_chat_fix.md
Normal 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`
|
||||
|
||||
## 🎯 Ожидаемый результат
|
||||
|
||||
После применения этого исправления:
|
||||
✅ Все пользователи будут получать сообщения в чате
|
||||
✅ Сообщения будут рассылаться **независимо от статуса регистрации**
|
||||
✅ Логирование позволит отследить проблемы при возникновении
|
||||
✅ Система корректно проверяет ключевые слова для выхода из чата
|
||||
✅ Сообщения рассылаются **всем** пользователям, включая незарегистрированных
|
||||
Reference in New Issue
Block a user