Compare commits
29 Commits
815cc544d5
...
V2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1830f77e | ||
|
|
b0e42688b6 | ||
|
|
c7c60b70c8 | ||
|
|
26c65b7caa | ||
|
|
6fbe4c4f43 | ||
|
|
3906398136 | ||
| 733298bf06 | |||
| 93f7ccdcf6 | |||
| dbba2c4b83 | |||
| 417ecf14d7 | |||
| fd8fc35f03 | |||
| f855772229 | |||
| df3d439e62 | |||
| 45d960746b | |||
| 7b50be5ae1 | |||
| 6089c90d22 | |||
| c5a90a5153 | |||
| 72f9d40a1a | |||
| 62ca809f11 | |||
| 9fe9e8958a | |||
|
|
21f348471e | ||
|
|
4daec268e6 | ||
| 5c01486bd8 | |||
| 782f702327 | |||
| ede4617b00 | |||
| 7d5ad3d668 | |||
| 904f94e1b5 | |||
| 06ddd1e5fa | |||
| b45fe005b9 |
17
.env.example
17
.env.example
@@ -9,6 +9,15 @@ BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Для PostgreSQL (рекомендуется для продакшена):
|
||||
DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot
|
||||
DB_CONNECT_TIMEOUT=5
|
||||
DB_POOL_SIZE=5
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_TIMEOUT=10
|
||||
DB_POOL_RECYCLE=1800
|
||||
|
||||
# === REDIS ===
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
USE_REDIS_STORAGE=true
|
||||
|
||||
# === АДМИНИСТРАТОРЫ ===
|
||||
# ID администраторов Telegram (через запятую)
|
||||
@@ -19,6 +28,12 @@ ADMIN_IDS=123456789,987654321
|
||||
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
|
||||
SQL_ECHO=false
|
||||
|
||||
# Обновлять last_activity не чаще указанного интервала на пользователя
|
||||
ACTIVITY_UPDATE_INTERVAL_SECONDS=300
|
||||
|
||||
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
|
||||
# Максимальное количество участников в одном розыгрыше
|
||||
# MAX_PARTICIPANTS_PER_LOTTERY=10000
|
||||
@@ -27,4 +42,4 @@ LOG_LEVEL=INFO
|
||||
# MAX_ACTIVE_LOTTERIES=10
|
||||
|
||||
# Таймаут для операций с базой данных (секунды)
|
||||
# DATABASE_TIMEOUT=30
|
||||
# DATABASE_TIMEOUT=30
|
||||
|
||||
12
.env.prod
12
.env.prod
@@ -2,17 +2,17 @@
|
||||
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
||||
|
||||
# Telegram Bot Token
|
||||
BOT_TOKEN=8125171867:AAHA0l2hGGodOUBh0rFlkE4CxK0X6JzZv64
|
||||
BOT_TOKEN=6804077170:AAGw_t6ktAiwYr2mrby0PUhckt50NZaEs0E
|
||||
|
||||
# PostgreSQL настройки для Docker контейнера
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_HOST=192.168.0.102
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=lottery_bot
|
||||
POSTGRES_USER=lottery_user
|
||||
POSTGRES_DB=new_lottery_KR
|
||||
POSTGRES_USER=trevor
|
||||
POSTGRES_PASSWORD=Cl0ud_1985!
|
||||
|
||||
# Database URL для бота (использует postgres как hostname внутри Docker сети)
|
||||
DATABASE_URL=postgresql+asyncpg://lottery_user:Cl0ud_1985!@postgres:5432/lottery_bot
|
||||
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/new_lottery_KR
|
||||
# Redis URL
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
@@ -20,4 +20,4 @@ REDIS_URL=redis://redis:6379/0
|
||||
ADMIN_IDS=556399210,6639865742
|
||||
|
||||
# Настройки логирования
|
||||
LOG_LEVEL=INFO
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
13
Makefile
13
Makefile
@@ -1,7 +1,10 @@
|
||||
# Makefile для телеграм-бота розыгрышей
|
||||
|
||||
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1)
|
||||
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null)
|
||||
# Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
|
||||
PYTHON ?= python3.12
|
||||
|
||||
# Определяем команду Docker Compose: предпочтительно v2 (`docker compose`), fallback v1 (`docker-compose`)
|
||||
DOCKER_COMPOSE ?= $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||
|
||||
.PHONY: help install setup setup-postgres init-db run test clean
|
||||
|
||||
@@ -36,7 +39,7 @@ help:
|
||||
# Установка зависимостей
|
||||
install:
|
||||
@echo "📦 Установка зависимостей..."
|
||||
python3 -m venv .venv
|
||||
$(PYTHON) -m venv .venv
|
||||
. .venv/bin/activate && pip install -r requirements.txt
|
||||
|
||||
# Настройка PostgreSQL базы данных
|
||||
@@ -217,7 +220,7 @@ docker-setup:
|
||||
|
||||
# Проверка Docker и Docker Compose
|
||||
docker-check:
|
||||
@echo "<EFBFBD> Проверка Docker окружения..."
|
||||
@echo "🔍 Проверка Docker окружения..."
|
||||
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
|
||||
@echo "✅ Docker: $$(docker --version)"
|
||||
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
|
||||
@@ -411,4 +414,4 @@ docker-external-db-help:
|
||||
@echo " 3. make docker-deploy"
|
||||
@echo ""
|
||||
@echo "Проверить подключение:"
|
||||
@echo " make docker-test-db"
|
||||
@echo " make docker-test-db"
|
||||
|
||||
@@ -7,16 +7,18 @@ services:
|
||||
image: postgres:15-alpine
|
||||
container_name: lottery_postgres
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- local-db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-lottery_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lottery_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-lottery}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- lottery_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -45,18 +47,23 @@ services:
|
||||
container_name: lottery_bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.prod
|
||||
- .env
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- USE_REDIS_STORAGE=${USE_REDIS_STORAGE:-true}
|
||||
- DB_CONNECT_TIMEOUT=${DB_CONNECT_TIMEOUT:-5}
|
||||
- DB_POOL_SIZE=${DB_POOL_SIZE:-5}
|
||||
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-10}
|
||||
- DB_POOL_TIMEOUT=${DB_POOL_TIMEOUT:-10}
|
||||
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
|
||||
- ACTIVITY_UPDATE_INTERVAL_SECONDS=${ACTIVITY_UPDATE_INTERVAL_SECONDS:-300}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- bot_data:/app/data
|
||||
networks:
|
||||
- lottery_network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
|
||||
244
docs/EMOJI_SYSTEM.md
Normal file
244
docs/EMOJI_SYSTEM.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Система управления кастомными эмодзи
|
||||
|
||||
## Обзор
|
||||
|
||||
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
|
||||
|
||||
1. Бот получает `emoji_id` от Telegram API
|
||||
2. Сохраняет эмодзи в таблице `emoji_mappings`
|
||||
3. При отправке сообщений в чаты бот автоматически использует `emoji_id` вместо текста эмодзи
|
||||
|
||||
Это обеспечивает, что эмодзи будут выглядеть точно так же, как их отправил админ, даже если это премиум эмодзи.
|
||||
|
||||
## Команды администратора
|
||||
|
||||
### 1. Добавить новый эмодзи
|
||||
|
||||
```
|
||||
/add_emoji
|
||||
```
|
||||
|
||||
Процесс:
|
||||
1. Админ запускает команду `/add_emoji`
|
||||
2. Бот просит отправить эмодзи
|
||||
3. Админ отправляет эмодзи (например, 🎲)
|
||||
4. Бот просит описание (для чего используется)
|
||||
5. Админ отправляет描述 (например, "Для лотереи")
|
||||
6. Бот сохраняет в БД и подтверждает
|
||||
|
||||
### 2. Просмотр своих эмодзи
|
||||
|
||||
```
|
||||
/my_emojis
|
||||
```
|
||||
|
||||
Показывает все эмодзи, добавленные этим админом:
|
||||
- Сам эмодзи
|
||||
- Описание
|
||||
- ID (первые 30 символов)
|
||||
- Дату добавления
|
||||
|
||||
### 3. Просмотр всех эмодзи в системе
|
||||
|
||||
```
|
||||
/all_emojis
|
||||
```
|
||||
|
||||
Показывает все эмодзи всех админов с информацией об администраторе
|
||||
|
||||
### 4. Удалить эмодзи
|
||||
|
||||
```
|
||||
/delete_emoji
|
||||
```
|
||||
|
||||
Админ может удалить только свои эмодзи. Процесс:
|
||||
1. Вызвать команду
|
||||
2. Выбрать эмодзи из список (кнопки)
|
||||
3. Бот удалит из БД
|
||||
|
||||
## Использование в коде
|
||||
|
||||
### Простой способ - прямое использование эмодзи
|
||||
|
||||
```python
|
||||
from aiogram.types import Message
|
||||
|
||||
async def handler(message: Message):
|
||||
await message.answer(
|
||||
text="🎲 Добро пожаловать на лотерею! 🏆",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
```
|
||||
|
||||
### С обработкой эмодзи
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.core.emoji_message_helper import get_emoji_aware_text
|
||||
from aiogram.types import Message
|
||||
|
||||
async def handler(message: Message, session: AsyncSession):
|
||||
# Текст с эмодзи
|
||||
original_text = "🎲 Выиграли! 🏆"
|
||||
|
||||
# Обработаны текст (эмодзи заменены на ID для корректного отображения)
|
||||
processed_text = await get_emoji_aware_text(session, original_text)
|
||||
|
||||
await message.answer(processed_text, parse_mode="HTML")
|
||||
```
|
||||
|
||||
### Работа с EmojiMessageHelper
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.core.emoji_message_helper import EmojiMessageHelper
|
||||
|
||||
async def handler(message: Message, session: AsyncSession):
|
||||
helper = EmojiMessageHelper(session)
|
||||
|
||||
# Обработка перед отправкой
|
||||
text = "🎲 Лотерея начинается! 💎"
|
||||
processed = await helper.process_text_before_send(text)
|
||||
|
||||
await message.answer(processed, parse_mode="HTML")
|
||||
```
|
||||
|
||||
## Структура БД
|
||||
|
||||
### Таблица `emoji_mappings`
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|---------|
|
||||
| `id` | Integer | Primary Key |
|
||||
| `emoji_text` | String(10) | Сам эмодзи (например, 🎲) |
|
||||
| `emoji_id` | String(255) | telegram_emoji_id от API (уникален) |
|
||||
| `admin_id` | Integer | FK на user (администратор) |
|
||||
| `description` | String(255) | Описание назначения эмодзи |
|
||||
| `created_at` | DateTime | Дата добавления |
|
||||
| `last_used_at` | DateTime | Последнее использование |
|
||||
|
||||
### Уникальные ограничения
|
||||
|
||||
- `emoji_id` — уникален во всей системе
|
||||
- `(emoji_text, admin_id)` — один админ не может добавить один эмодзи дважды
|
||||
|
||||
## API сервиса EmojiMappingService
|
||||
|
||||
### Регистрация эмодзи
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.core.emoji_mapping_service import EmojiMappingService
|
||||
|
||||
async with async_session_maker() as session:
|
||||
service = EmojiMappingService(session)
|
||||
|
||||
emoji = await service.register_emoji(
|
||||
emoji_text="🎲",
|
||||
emoji_id="telegram_emoji_id_here",
|
||||
admin_id=12345,
|
||||
description="Для лотереи"
|
||||
)
|
||||
```
|
||||
|
||||
### Получение эмодзи
|
||||
|
||||
```python
|
||||
# По тексту
|
||||
emoji = await service.get_emoji_by_text("🎲")
|
||||
|
||||
# По emoji_id
|
||||
emoji = await service.get_emoji_by_id("telegram_emoji_id")
|
||||
|
||||
# Все эмодзи админа
|
||||
emojis = await service.get_all_emoji_by_admin(admin_id=12345)
|
||||
|
||||
# Все эмодзи
|
||||
all_emojis = await service.get_all_emojis()
|
||||
```
|
||||
|
||||
### Замена эмодзи в тексте
|
||||
|
||||
```python
|
||||
# Текст → с заменой эмодзи на ID
|
||||
processed = await service.replace_emojis_in_text(
|
||||
"🎲 Выиграли! 🏆"
|
||||
)
|
||||
|
||||
# Обратно - ID → эмодзи
|
||||
original = await service.restore_emojis_in_text(processed)
|
||||
```
|
||||
|
||||
### Получить словарь маппинга
|
||||
|
||||
```python
|
||||
# {emoji_text: emoji_id}
|
||||
mapping = await service.get_emoji_mapping_dict()
|
||||
# {'🎲': 'telegram_emoji_id_1', '🏆': 'telegram_emoji_id_2', ...}
|
||||
```
|
||||
|
||||
## Примеры использования в разных рутерах
|
||||
|
||||
### В регистрации
|
||||
|
||||
```python
|
||||
async def registration_complete(message: Message, session: AsyncSession):
|
||||
text = "✅ Регистрация завершена! 🎉"
|
||||
text = await get_emoji_aware_text(session, text)
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
```
|
||||
|
||||
### В админ-панели
|
||||
|
||||
```python
|
||||
async def lottery_created(callback: CallbackQuery, session: AsyncSession):
|
||||
text = "🎰 Новый розыгрыш создан! 🏆"
|
||||
text = await get_emoji_aware_text(session, text)
|
||||
await callback.message.edit_text(text, parse_mode="HTML")
|
||||
```
|
||||
|
||||
### В чатовой рассылке
|
||||
|
||||
```python
|
||||
async def broadcast_message(message: Message, session: AsyncSession):
|
||||
text = f"📢 Сообщение от админа: {message.text}\n\n💎 Удачи!"
|
||||
text = await get_emoji_aware_text(session, text)
|
||||
|
||||
for user_id in target_users:
|
||||
await bot.send_message(user_id, text, parse_mode="HTML")
|
||||
```
|
||||
|
||||
## Важные моменты
|
||||
|
||||
1. **Parse Mode**: Всегда используйте `parse_mode="HTML"` при работе с эмодзи
|
||||
2. **Кеширование ID**: Система не кеширует, каждый раз обращается к БД. Для оптимизации можно добавить кеширование
|
||||
3. **Лог использования**: `last_used_at` обновляется автоматически при замене в тексте
|
||||
4. **Удаление**: Удаленный эмодзи больше не будет заменяться в новых сообщениях
|
||||
5. **Конфликты**: Если два админа добавляют один эмодзи - они сохранятся отдельно (разные admin_id)
|
||||
|
||||
## Миграция
|
||||
|
||||
Таблица создана миграцией:
|
||||
```
|
||||
migrations/versions/20260307_0100_add_emoji_mappings.py
|
||||
```
|
||||
|
||||
Применить миграцию:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Trouble Shooting
|
||||
|
||||
### Эмодзи не отображается корректно
|
||||
- Проверьте что используете `parse_mode="HTML"`
|
||||
- Убедитесь что эмодзи зарегистрирован с помощью `/my_emojis`
|
||||
|
||||
### Ошибка "Can't parse entities"
|
||||
- Это означает что есть конфликт форматирования
|
||||
- Убедитесь что используете HTML теги (`<b>`, `<i>`, и т.д.), а не Markdown (`**`, `__`)
|
||||
|
||||
### Эмодзи не заменяется
|
||||
- Проверьте что был зарегистрирован с помощью `/add_emoji`
|
||||
- Убедитесь что используете функцию `get_emoji_aware_text()` перед отправкой
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:40:31.898764",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:42:08.014799",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"export_date": "2026-02-08T17:42:21.844218",
|
||||
"statistics": {
|
||||
"users": 3,
|
||||
"lotteries": 1,
|
||||
"participations": 1,
|
||||
"winners": 0
|
||||
}
|
||||
}
|
||||
86
main.py
86
main.py
@@ -10,11 +10,12 @@ from aiogram import Bot, Dispatcher, Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
|
||||
from src.core.config import BOT_TOKEN
|
||||
from src.core.config import BOT_TOKEN, LOG_LEVEL, REDIS_URL, USE_REDIS_STORAGE
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.scheduler import bot_scheduler
|
||||
from src.container import container
|
||||
@@ -30,17 +31,34 @@ 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
|
||||
from src.utils.task_manager import task_manager
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создание бота и диспетчера
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
def create_fsm_storage():
|
||||
if not USE_REDIS_STORAGE:
|
||||
logger.warning("FSM storage: используется MemoryStorage")
|
||||
return MemoryStorage()
|
||||
|
||||
try:
|
||||
logger.info("FSM storage: используется RedisStorage")
|
||||
return RedisStorage.from_url(REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать RedisStorage, используется MemoryStorage: {e}")
|
||||
return MemoryStorage()
|
||||
|
||||
|
||||
storage = create_fsm_storage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
router = Router()
|
||||
|
||||
@@ -49,9 +67,9 @@ router = Router()
|
||||
@dp.callback_query.middleware()
|
||||
async def log_callback_middleware(handler, event, data):
|
||||
"""Middleware для логирования всех callback запросов"""
|
||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
|
||||
logger.debug(f"Callback received: data={event.data!r}, user_id={event.from_user.id}")
|
||||
result = await handler(event, data)
|
||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
|
||||
logger.debug(f"Callback handled: data={event.data!r}, result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -134,18 +152,41 @@ async def btn_chat(message: Message, state: FSMContext):
|
||||
@router.message(F.text == "📝 Регистрация")
|
||||
async def btn_registration(message: Message, state: FSMContext):
|
||||
"""Обработчик кнопки 'Регистрация'"""
|
||||
from aiogram.types import CallbackQuery
|
||||
from src.handlers.registration_handlers import RegistrationStates
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
fake_callback = CallbackQuery(
|
||||
id="fake",
|
||||
from_user=message.from_user,
|
||||
chat_instance="0",
|
||||
data="start_registration",
|
||||
message=message
|
||||
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"
|
||||
"• Это имя будут видеть другие участники"
|
||||
)
|
||||
|
||||
from src.handlers.registration_handlers import start_registration
|
||||
await start_registration(fake_callback, state)
|
||||
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)
|
||||
|
||||
|
||||
@router.message(F.text == "🔑 Мой код")
|
||||
@@ -155,9 +196,9 @@ async def btn_my_code(message: Message):
|
||||
await show_verification_code(message)
|
||||
|
||||
|
||||
@router.message(F.text == "💳 Мои счета")
|
||||
@router.message(F.text == "📱 Мои логины")
|
||||
async def btn_my_accounts(message: Message):
|
||||
"""Обработчик кнопки 'Мои счета'"""
|
||||
"""Обработчик кнопки 'Мои логины'"""
|
||||
from src.handlers.registration_handlers import show_user_accounts
|
||||
await show_user_accounts(message)
|
||||
|
||||
@@ -182,9 +223,9 @@ async def btn_exit_chat(message: Message, state: FSMContext):
|
||||
await exit_chat(message, state)
|
||||
|
||||
|
||||
@router.message(F.text == "🏠 Главное меню")
|
||||
@router.message(F.text == "🏠 Главная")
|
||||
async def btn_main_menu(message: Message):
|
||||
"""Обработчик кнопки 'Главное меню'"""
|
||||
"""Обработчик кнопки 'Главная'"""
|
||||
await cmd_start(message)
|
||||
|
||||
|
||||
@@ -266,6 +307,7 @@ 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) # Админские команды счетов
|
||||
@@ -283,6 +325,9 @@ async def main():
|
||||
# Запускаем планировщик задач
|
||||
bot_scheduler.start()
|
||||
logger.info("Планировщик задач запущен")
|
||||
|
||||
await task_manager.start()
|
||||
logger.info("Менеджер фоновых задач запущен")
|
||||
|
||||
# Запускаем polling
|
||||
try:
|
||||
@@ -293,6 +338,9 @@ async def main():
|
||||
finally:
|
||||
# Останавливаем планировщик
|
||||
bot_scheduler.shutdown()
|
||||
await task_manager.stop()
|
||||
if hasattr(storage, "close"):
|
||||
await storage.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@@ -302,4 +350,4 @@ if __name__ == "__main__":
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка: {e}")
|
||||
logger.error(f"Критическая ошибка: {e}")
|
||||
|
||||
@@ -17,72 +17,10 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('blocked_users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('error_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('attempt_count', sa.Integer(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
|
||||
op.create_table('broadcast_channels',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('chat_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('username', sa.String(length=255), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('added_by', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
|
||||
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
|
||||
op.create_table('broadcast_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('target_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('message_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('message_text', sa.Text(), nullable=True),
|
||||
sa.Column('file_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('total_recipients', sa.Integer(), nullable=True),
|
||||
sa.Column('success_count', sa.Integer(), nullable=True),
|
||||
sa.Column('failed_count', sa.Integer(), nullable=True),
|
||||
sa.Column('blocked_count', sa.Integer(), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
|
||||
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
|
||||
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=True))
|
||||
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
# Duplicate autogenerated migration. The real changes are applied by
|
||||
# 71376bb89294, 1f1631301809 and b4c435a7dc5f.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'last_activity')
|
||||
op.drop_column('users', 'is_chat_banned')
|
||||
op.drop_index(op.f('ix_broadcast_logs_status'), table_name='broadcast_logs')
|
||||
op.drop_index(op.f('ix_broadcast_logs_broadcast_type'), table_name='broadcast_logs')
|
||||
op.drop_table('broadcast_logs')
|
||||
op.drop_index(op.f('ix_broadcast_channels_is_active'), table_name='broadcast_channels')
|
||||
op.drop_index(op.f('ix_broadcast_channels_chat_id'), table_name='broadcast_channels')
|
||||
op.drop_table('broadcast_channels')
|
||||
op.drop_index(op.f('ix_blocked_users_telegram_id'), table_name='blocked_users')
|
||||
op.drop_index(op.f('ix_blocked_users_is_active'), table_name='blocked_users')
|
||||
op.drop_table('blocked_users')
|
||||
# ### end Alembic commands ###
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""merge branches
|
||||
|
||||
Revision ID: merge_migration
|
||||
Revises: 41aae82e631b, cd31303a681c
|
||||
Revises: cd31303a681c, 41aae82e631b
|
||||
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 = ('41aae82e631b', 'cd31303a681c')
|
||||
down_revision = ('cd31303a681c', '41aae82e631b')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
45
migrations/versions/20260307_0100_add_emoji_mappings.py
Normal file
45
migrations/versions/20260307_0100_add_emoji_mappings.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Add emoji_mappings table for storing custom emoji IDs
|
||||
|
||||
Revision ID: 20260307_0100_add_emoji_mappings
|
||||
Revises: merge_migration
|
||||
Create Date: 2026-03-07 01:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20260307_0100_add_emoji_mappings'
|
||||
down_revision = 'merge_migration'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'emoji_mappings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('emoji_text', sa.String(length=10), nullable=False),
|
||||
sa.Column('emoji_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('admin_id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('emoji_id', name='emoji_mappings_emoji_id_key'),
|
||||
sa.UniqueConstraint('emoji_text', 'admin_id', name='unique_emoji_per_admin'),
|
||||
)
|
||||
op.create_index('ix_emoji_mappings_emoji_id', 'emoji_mappings', ['emoji_id'], unique=True)
|
||||
op.create_index('ix_emoji_mappings_emoji_text', 'emoji_mappings', ['emoji_text'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_emoji_mappings_emoji_text', table_name='emoji_mappings')
|
||||
op.drop_index('ix_emoji_mappings_emoji_id', table_name='emoji_mappings')
|
||||
op.drop_table('emoji_mappings')
|
||||
# ### end Alembic commands ###
|
||||
50
migrations/versions/20260701_0001_add_performance_indexes.py
Normal file
50
migrations/versions/20260701_0001_add_performance_indexes.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""add performance indexes
|
||||
|
||||
Revision ID: 20260701_perf_indexes
|
||||
Revises: 20260307_0100_add_emoji_mappings
|
||||
Create Date: 2026-07-01 00:01:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '20260701_perf_indexes'
|
||||
down_revision = '20260307_0100_add_emoji_mappings'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_registered_activity "
|
||||
"ON users (is_registered, last_activity)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_participations_lottery_account "
|
||||
"ON participations (lottery_id, account_number)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_participations_user_created "
|
||||
"ON participations (user_id, created_at)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_winners_lottery_place "
|
||||
"ON winners (lottery_id, place)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_winners_user_id "
|
||||
"ON winners (user_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_blocked_users_lookup "
|
||||
"ON blocked_users (telegram_id, error_type, is_active)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_blocked_users_lookup")
|
||||
op.execute("DROP INDEX IF EXISTS ix_winners_user_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_winners_lottery_place")
|
||||
op.execute("DROP INDEX IF EXISTS ix_participations_user_created")
|
||||
op.execute("DROP INDEX IF EXISTS ix_participations_lottery_account")
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_registered_activity")
|
||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "new-lottery-bot"
|
||||
version = "2.0.0"
|
||||
description = "Telegram lottery bot"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiogram==3.29.0",
|
||||
"aiohttp==3.14.1",
|
||||
"SQLAlchemy==2.0.51",
|
||||
"alembic==1.18.5",
|
||||
"python-dotenv==1.2.2",
|
||||
"asyncpg==0.31.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"redis==8.0.1",
|
||||
"APScheduler==3.11.3",
|
||||
"openpyxl==3.1.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.1.1",
|
||||
"pytest-asyncio==1.4.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -1,12 +1,15 @@
|
||||
# Updated for Python 3.12 compatibility
|
||||
aiogram==3.16.0
|
||||
aiohttp>=3.11.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
python-dotenv==1.0.1
|
||||
asyncpg==0.30.0
|
||||
aiosqlite==0.20.0
|
||||
redis==5.2.1
|
||||
aioredis==2.0.1
|
||||
apscheduler==3.10.4
|
||||
openpyxl==3.1.2
|
||||
# Runtime target: Python 3.12+
|
||||
aiogram==3.29.0
|
||||
aiohttp==3.14.1
|
||||
SQLAlchemy==2.0.51
|
||||
alembic==1.18.5
|
||||
python-dotenv==1.2.2
|
||||
asyncpg==0.31.0
|
||||
aiosqlite==0.22.1
|
||||
redis==8.0.1
|
||||
APScheduler==3.11.3
|
||||
openpyxl==3.1.5
|
||||
|
||||
# Development / diagnostics
|
||||
pytest==9.1.1
|
||||
pytest-asyncio==1.4.0
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
Скрипт для очистки и инициализации БД с тестовыми данными
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from database import async_session_maker, Base, engine
|
||||
from models import User, Lottery, Participation, Winner
|
||||
from services import LotteryService, UserService, ParticipationService
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import delete, update, select, func
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.models import User, Lottery, Participation, Winner, Account
|
||||
from src.core.services import LotteryService, UserService, ParticipationService
|
||||
from sqlalchemy import delete, select, func
|
||||
|
||||
async def clear_database():
|
||||
"""Очистка всех таблиц"""
|
||||
@@ -18,6 +16,7 @@ async def clear_database():
|
||||
# Удаляем все данные в правильном порядке (учитывая внешние ключи)
|
||||
await session.execute(delete(Winner))
|
||||
await session.execute(delete(Participation))
|
||||
await session.execute(delete(Account))
|
||||
await session.execute(delete(Lottery))
|
||||
await session.execute(delete(User))
|
||||
await session.commit()
|
||||
@@ -26,8 +25,10 @@ async def clear_database():
|
||||
|
||||
def generate_account_numbers(user_id: int, count: int = 10) -> list:
|
||||
"""Генерация номеров счетов для пользователя"""
|
||||
base = user_id * 1000
|
||||
return [f"{base + i:06d}" for i in range(1, count + 1)]
|
||||
return [
|
||||
"-".join(f"{part:02d}" for part in [user_id, i, 11, 22, 33, 44, 55])
|
||||
for i in range(1, count + 1)
|
||||
]
|
||||
|
||||
async def create_test_users():
|
||||
"""Создание тестовых пользователей"""
|
||||
@@ -62,12 +63,8 @@ async def create_test_users():
|
||||
# Генерируем и присваиваем номера счетов
|
||||
account_numbers = generate_account_numbers(i, 10)
|
||||
|
||||
# Обновляем счета
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(account_number=",".join(account_numbers))
|
||||
)
|
||||
for account_number in account_numbers:
|
||||
await UserService.set_account_number(session, user.telegram_id, account_number)
|
||||
|
||||
await session.commit()
|
||||
created_users.append((user, account_numbers))
|
||||
@@ -194,4 +191,4 @@ async def main():
|
||||
print("\n🎉 Инициализация завершена!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Примеры использования API сервисов для тестирования
|
||||
"""
|
||||
import asyncio
|
||||
from database import async_session_maker, init_db
|
||||
from services import UserService, LotteryService, ParticipationService
|
||||
from src.core.database import async_session_maker, init_db
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
|
||||
|
||||
async def example_usage():
|
||||
@@ -61,7 +61,7 @@ async def example_usage():
|
||||
|
||||
# Добавляем участников
|
||||
for user in users:
|
||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
||||
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||
if success:
|
||||
print(f"✅ {user.first_name} присоединился к розыгрышу")
|
||||
|
||||
@@ -93,6 +93,7 @@ async def example_usage():
|
||||
|
||||
# Проводим розыгрыш
|
||||
results = await LotteryService.conduct_draw(session, lottery.id)
|
||||
await session.commit()
|
||||
|
||||
print(f"\n🏆 Результаты розыгрыша:")
|
||||
print(f"🎯 Розыгрыш: {lottery.title}")
|
||||
@@ -134,7 +135,7 @@ async def test_edge_cases():
|
||||
user = await UserService.get_user_by_telegram_id(session, 100000001)
|
||||
lottery = (await LotteryService.get_active_lotteries(session))[0]
|
||||
|
||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
||||
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||
print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
|
||||
|
||||
# Попытка установить ручного победителя для несуществующего пользователя
|
||||
|
||||
@@ -45,7 +45,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||
|
||||
class MessageFormatterImpl(IMessageFormatter):
|
||||
"""Реализация форматирования сообщений"""
|
||||
|
||||
|
||||
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
|
||||
"""Получить клавиатуру для конкретного розыгрыша"""
|
||||
buttons = [
|
||||
@@ -72,12 +72,8 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text = text[:47] + "..."
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")])
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
class MessageFormatterImpl(IMessageFormatter):
|
||||
"""Реализация форматирования сообщений"""
|
||||
|
||||
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
||||
"""Форматировать информацию о розыгрыше"""
|
||||
@@ -134,4 +130,4 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text += f"✅ Завершенных розыгрышей: {stats.get('completed_lotteries', 0)}\n"
|
||||
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
|
||||
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
|
||||
from src.interfaces.base import ILotteryRepository, IParticipationRepository
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.telegram_config import get_parse_mode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +36,9 @@ 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,
|
||||
@@ -43,6 +47,9 @@ 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"
|
||||
|
||||
@@ -82,7 +89,7 @@ class BotController(IBotController):
|
||||
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
||||
return
|
||||
|
||||
text = "🎲 **Активные розыгрыши:**\n\n"
|
||||
text = "🎲 <b>Активные розыгрыши:</b>\n\n"
|
||||
|
||||
for lottery in lotteries:
|
||||
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
||||
@@ -106,7 +113,7 @@ class BotController(IBotController):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
parse_mode=get_parse_mode("inline_keyboard")
|
||||
)
|
||||
except Exception as e:
|
||||
# Если сообщение не изменилось - просто отвечаем на callback
|
||||
@@ -118,5 +125,5 @@ class BotController(IBotController):
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
parse_mode=get_parse_mode("inline_keyboard")
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import select, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from .models import User, BlockedUser
|
||||
@@ -40,6 +40,34 @@ class ActivityService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
@staticmethod
|
||||
async def update_activity_and_reactivate(session: AsyncSession, telegram_id: int) -> Optional[bool]:
|
||||
"""Обновить активность и снять блокировку за неактивность одним коммитом."""
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
.values(last_activity=now)
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
update(BlockedUser)
|
||||
.where(
|
||||
and_(
|
||||
BlockedUser.telegram_id == telegram_id,
|
||||
BlockedUser.is_active == True,
|
||||
)
|
||||
)
|
||||
.values(is_active=False, last_attempt_at=now)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления активности/реактивации пользователя {telegram_id}: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_inactive_users(
|
||||
@@ -84,35 +112,45 @@ class ActivityService:
|
||||
Количество помеченных пользователей
|
||||
"""
|
||||
try:
|
||||
inactive_users = await ActivityService.get_inactive_users(session, days)
|
||||
marked_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
# Проверяем, не помечен ли уже
|
||||
stmt = select(BlockedUser).where(
|
||||
if days is None:
|
||||
days = ActivityService.INACTIVITY_PERIOD_DAYS
|
||||
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
already_blocked = (
|
||||
select(BlockedUser.id)
|
||||
.where(
|
||||
and_(
|
||||
BlockedUser.telegram_id == user.telegram_id,
|
||||
BlockedUser.telegram_id == User.telegram_id,
|
||||
BlockedUser.error_type == 'inactive',
|
||||
BlockedUser.is_active == True
|
||||
BlockedUser.is_active == True,
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
# Создаем новую запись
|
||||
blocked = BlockedUser(
|
||||
telegram_id=user.telegram_id,
|
||||
error_type='inactive',
|
||||
error_message=f'User inactive for {days} days',
|
||||
first_blocked_at=datetime.now(timezone.utc),
|
||||
last_attempt_at=datetime.now(timezone.utc),
|
||||
attempt_count=1,
|
||||
is_active=True
|
||||
.exists()
|
||||
)
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.last_activity < cutoff_date,
|
||||
User.is_registered == True,
|
||||
~already_blocked,
|
||||
)
|
||||
session.add(blocked)
|
||||
marked_count += 1
|
||||
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
|
||||
)
|
||||
)
|
||||
inactive_users = list(result.scalars().all())
|
||||
marked_count = 0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for user in inactive_users:
|
||||
session.add(BlockedUser(
|
||||
telegram_id=user.telegram_id,
|
||||
error_type='inactive',
|
||||
error_message=f'User inactive for {days} days',
|
||||
first_blocked_at=now,
|
||||
last_attempt_at=now,
|
||||
attempt_count=1,
|
||||
is_active=True
|
||||
))
|
||||
marked_count += 1
|
||||
|
||||
await session.commit()
|
||||
return marked_count
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Optional, List, Dict, Tuple, Any
|
||||
from datetime import datetime, timezone
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import redis.asyncio as redis
|
||||
@@ -16,6 +15,7 @@ import redis.asyncio as redis
|
||||
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
||||
from .config import REDIS_URL, ADMIN_IDS
|
||||
from .database import async_session_maker
|
||||
from .delivery import delivery_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,6 +93,7 @@ class BroadcastService:
|
||||
BATCH_SIZE = 30 # Сообщений в пакете
|
||||
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
|
||||
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
|
||||
MAX_RETRY_AFTER = 30
|
||||
|
||||
def __init__(self):
|
||||
self.redis_queue = RedisQueue()
|
||||
@@ -131,29 +132,9 @@ class BroadcastService:
|
||||
error_type: Тип ошибки
|
||||
error_message: Сообщение об ошибке
|
||||
"""
|
||||
# Проверяем, есть ли уже запись
|
||||
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||
result = await session.execute(stmt)
|
||||
blocked_user = result.scalar_one_or_none()
|
||||
|
||||
if blocked_user:
|
||||
# Обновляем существующую запись
|
||||
blocked_user.error_type = error_type
|
||||
blocked_user.error_message = error_message
|
||||
blocked_user.last_attempt_at = datetime.now(timezone.utc)
|
||||
blocked_user.attempt_count += 1
|
||||
blocked_user.is_active = True
|
||||
else:
|
||||
# Создаем новую запись
|
||||
blocked_user = BlockedUser(
|
||||
telegram_id=telegram_id,
|
||||
error_type=error_type,
|
||||
error_message=error_message
|
||||
)
|
||||
session.add(blocked_user)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
||||
await delivery_service.mark_user_blocked(
|
||||
session, telegram_id, error_type, error_message
|
||||
)
|
||||
|
||||
async def unblock_user(self, session: AsyncSession, telegram_id: int):
|
||||
"""
|
||||
@@ -163,23 +144,15 @@ class BroadcastService:
|
||||
session: Сессия БД
|
||||
telegram_id: Telegram ID пользователя
|
||||
"""
|
||||
stmt = select(BlockedUser).where(
|
||||
BlockedUser.telegram_id == telegram_id,
|
||||
BlockedUser.is_active == True
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
blocked_user = result.scalar_one_or_none()
|
||||
|
||||
if blocked_user:
|
||||
blocked_user.is_active = False
|
||||
await session.commit()
|
||||
logger.info(f"Пользователь {telegram_id} разблокирован")
|
||||
await delivery_service.unblock_user(session, telegram_id)
|
||||
|
||||
async def send_message_to_user(
|
||||
self,
|
||||
bot: Bot,
|
||||
user: User,
|
||||
message: Message
|
||||
message: Message,
|
||||
retry: bool = True,
|
||||
skip_block_check: bool = False,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Отправить сообщение пользователю с обработкой ошибок
|
||||
@@ -192,86 +165,43 @@ class BroadcastService:
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (успех, тип_ошибки)
|
||||
"""
|
||||
try:
|
||||
# Проверяем, не заблокирован ли пользователь
|
||||
async with async_session_maker() as session:
|
||||
is_blocked = await self.check_user_blocked(session, user.telegram_id)
|
||||
if is_blocked:
|
||||
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
|
||||
return False, "blocked"
|
||||
|
||||
# Отправляем сообщение
|
||||
if message.text:
|
||||
await bot.send_message(
|
||||
if message.text:
|
||||
send_call = lambda: bot.send_message(
|
||||
user.telegram_id,
|
||||
message.text,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.photo:
|
||||
await bot.send_photo(
|
||||
elif message.photo:
|
||||
send_call = lambda: bot.send_photo(
|
||||
user.telegram_id,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.video:
|
||||
await bot.send_video(
|
||||
elif message.video:
|
||||
send_call = lambda: bot.send_video(
|
||||
user.telegram_id,
|
||||
video=message.video.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.document:
|
||||
await bot.send_document(
|
||||
elif message.document:
|
||||
send_call = lambda: bot.send_document(
|
||||
user.telegram_id,
|
||||
document=message.document.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
# Копируем сообщение как есть
|
||||
await message.copy_to(user.telegram_id)
|
||||
|
||||
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
||||
async with async_session_maker() as session:
|
||||
await self.unblock_user(session, user.telegram_id)
|
||||
|
||||
return True, None
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
# Пользователь заблокировал бота
|
||||
error_type = "blocked_bot"
|
||||
async with async_session_maker() as session:
|
||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||
return False, error_type
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
# Пользователь удален или деактивирован
|
||||
error_str = str(e).lower()
|
||||
if "user is deactivated" in error_str:
|
||||
error_type = "deactivated"
|
||||
elif "user not found" in error_str:
|
||||
error_type = "not_found"
|
||||
elif "chat not found" in error_str:
|
||||
error_type = "chat_not_found"
|
||||
else:
|
||||
error_type = "bad_request"
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||
return False, error_type
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# FloodWait - слишком много запросов
|
||||
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
|
||||
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
|
||||
# Повторная попытка
|
||||
return await self.send_message_to_user(bot, user, message)
|
||||
|
||||
except Exception as e:
|
||||
# Другие ошибки
|
||||
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
|
||||
return False, "unknown_error"
|
||||
else:
|
||||
send_call = lambda: message.copy_to(user.telegram_id)
|
||||
|
||||
result = await delivery_service.send_with_guard(
|
||||
user.telegram_id,
|
||||
send_call,
|
||||
retry=retry,
|
||||
skip_block_check=skip_block_check,
|
||||
)
|
||||
return result.success, None if result.success else result.status
|
||||
|
||||
async def broadcast_to_users(
|
||||
self,
|
||||
@@ -307,6 +237,8 @@ class BroadcastService:
|
||||
await session.refresh(broadcast_log)
|
||||
log_id = broadcast_log.id
|
||||
|
||||
users_prefiltered = False
|
||||
|
||||
# Получаем список пользователей
|
||||
if users is None:
|
||||
async with async_session_maker() as session:
|
||||
@@ -324,6 +256,7 @@ class BroadcastService:
|
||||
|
||||
# Фильтруем пользователей, исключая заблокированных
|
||||
users = [u for u in all_users if u.telegram_id not in blocked_ids]
|
||||
users_prefiltered = True
|
||||
|
||||
total_users = len(users)
|
||||
success_count = 0
|
||||
@@ -337,7 +270,11 @@ class BroadcastService:
|
||||
# Отправляем пакет
|
||||
tasks = []
|
||||
for user in batch:
|
||||
tasks.append(self.send_message_to_user(bot, user, message))
|
||||
tasks.append(
|
||||
self.send_message_to_user(
|
||||
bot, user, message, skip_block_check=users_prefiltered
|
||||
)
|
||||
)
|
||||
|
||||
# Ждем завершения пакета
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -11,9 +11,18 @@ if not BOT_TOKEN:
|
||||
|
||||
# База данных
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
|
||||
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
||||
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
|
||||
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
|
||||
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
|
||||
# Redis
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
USE_REDIS_STORAGE = os.getenv("USE_REDIS_STORAGE", "true").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
# Активность пользователей
|
||||
ACTIVITY_UPDATE_INTERVAL_SECONDS = int(os.getenv("ACTIVITY_UPDATE_INTERVAL_SECONDS", "300"))
|
||||
|
||||
# Администраторы
|
||||
ADMIN_IDS = []
|
||||
@@ -29,4 +38,4 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Настройки бота
|
||||
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
|
||||
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
||||
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
||||
|
||||
@@ -8,12 +8,34 @@ load_dotenv()
|
||||
|
||||
# Конфигурация базы данных
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||
SQL_ECHO = os.getenv("SQL_ECHO", "false").lower() in {"1", "true", "yes", "on"}
|
||||
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
|
||||
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
||||
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
|
||||
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
|
||||
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
|
||||
# Создаем асинхронный движок
|
||||
engine_kwargs = {
|
||||
"echo": SQL_ECHO,
|
||||
"future": True,
|
||||
}
|
||||
|
||||
if DATABASE_URL.startswith("postgresql+asyncpg"):
|
||||
engine_kwargs.update(
|
||||
{
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": DB_POOL_SIZE,
|
||||
"max_overflow": DB_MAX_OVERFLOW,
|
||||
"pool_timeout": DB_POOL_TIMEOUT,
|
||||
"pool_recycle": DB_POOL_RECYCLE,
|
||||
"connect_args": {"timeout": DB_CONNECT_TIMEOUT},
|
||||
}
|
||||
)
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=True, # Логирование SQL запросов
|
||||
future=True,
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
# Создаем фабрику сессий
|
||||
@@ -38,4 +60,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
"""Закрытие соединения с базой данных"""
|
||||
await engine.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
185
src/core/delivery.py
Normal file
185
src/core/delivery.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Safe Telegram delivery helpers.
|
||||
|
||||
The bot sends messages from several places: broadcasts, winner notifications,
|
||||
P2P chat, and chat forwarding. This module keeps the "user blocked the bot"
|
||||
handling in one place so every outbound path behaves consistently.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .database import async_session_maker
|
||||
from .models import BlockedUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeliveryResult:
|
||||
success: bool
|
||||
status: Optional[str] = None
|
||||
telegram_message: Any = None
|
||||
skipped: bool = False
|
||||
|
||||
|
||||
class DeliveryService:
|
||||
"""Guarded Telegram delivery with persistent blocked-user tracking."""
|
||||
|
||||
RETRY_AFTER_DELAY = 5.0
|
||||
MAX_RETRY_AFTER = 30
|
||||
|
||||
_BAD_REQUEST_ERROR_TYPES = {
|
||||
"user is deactivated": "deactivated",
|
||||
"user not found": "not_found",
|
||||
"chat not found": "chat_not_found",
|
||||
"bot was blocked by the user": "blocked_bot",
|
||||
"forbidden": "blocked_bot",
|
||||
}
|
||||
|
||||
async def check_user_blocked(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
) -> Optional[BlockedUser]:
|
||||
result = await session.execute(
|
||||
select(BlockedUser).where(
|
||||
BlockedUser.telegram_id == telegram_id,
|
||||
BlockedUser.is_active == True,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def mark_user_blocked(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||
)
|
||||
blocked_user = result.scalar_one_or_none()
|
||||
|
||||
if blocked_user:
|
||||
blocked_user.error_type = error_type
|
||||
blocked_user.error_message = error_message
|
||||
blocked_user.last_attempt_at = now
|
||||
blocked_user.attempt_count = (blocked_user.attempt_count or 0) + 1
|
||||
blocked_user.is_active = True
|
||||
else:
|
||||
session.add(
|
||||
BlockedUser(
|
||||
telegram_id=telegram_id,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
first_blocked_at=now,
|
||||
last_attempt_at=now,
|
||||
attempt_count=1,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info("User %s marked as unavailable: %s", telegram_id, error_type)
|
||||
|
||||
async def unblock_user(self, session: AsyncSession, telegram_id: int) -> bool:
|
||||
blocked_user = await self.check_user_blocked(session, telegram_id)
|
||||
if not blocked_user:
|
||||
return False
|
||||
|
||||
blocked_user.is_active = False
|
||||
blocked_user.last_attempt_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
logger.info("User %s delivery reactivated", telegram_id)
|
||||
return True
|
||||
|
||||
async def send_with_guard(
|
||||
self,
|
||||
telegram_id: int,
|
||||
send_call: Callable[[], Awaitable[Any]],
|
||||
*,
|
||||
retry: bool = True,
|
||||
skip_block_check: bool = False,
|
||||
) -> DeliveryResult:
|
||||
"""
|
||||
Execute a Telegram send call unless the recipient is known unavailable.
|
||||
|
||||
Args:
|
||||
telegram_id: recipient Telegram ID.
|
||||
send_call: zero-argument async callable that performs the actual
|
||||
aiogram send/copy operation and returns Telegram Message.
|
||||
retry: retry once after TelegramRetryAfter when the delay is small.
|
||||
skip_block_check: caller already filtered blocked users.
|
||||
"""
|
||||
if not skip_block_check:
|
||||
async with async_session_maker() as session:
|
||||
blocked_user = await self.check_user_blocked(session, telegram_id)
|
||||
if blocked_user:
|
||||
return DeliveryResult(
|
||||
success=False,
|
||||
status=blocked_user.error_type,
|
||||
skipped=True,
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_message = await send_call()
|
||||
except TelegramForbiddenError as exc:
|
||||
await self._mark_unavailable(telegram_id, "blocked_bot", str(exc))
|
||||
return DeliveryResult(success=False, status="blocked_bot")
|
||||
except TelegramBadRequest as exc:
|
||||
error_type = self.classify_bad_request(exc)
|
||||
if error_type:
|
||||
await self._mark_unavailable(telegram_id, error_type, str(exc))
|
||||
return DeliveryResult(success=False, status=error_type)
|
||||
|
||||
logger.warning("Telegram bad request for %s: %s", telegram_id, exc)
|
||||
return DeliveryResult(success=False, status="bad_request")
|
||||
except TelegramRetryAfter as exc:
|
||||
if retry and exc.retry_after <= self.MAX_RETRY_AFTER:
|
||||
logger.warning("FloodWait for %s: %s sec", telegram_id, exc.retry_after)
|
||||
await asyncio.sleep(exc.retry_after + self.RETRY_AFTER_DELAY)
|
||||
return await self.send_with_guard(
|
||||
telegram_id,
|
||||
send_call,
|
||||
retry=False,
|
||||
skip_block_check=True,
|
||||
)
|
||||
|
||||
logger.warning("Skipping %s after FloodWait %s sec", telegram_id, exc.retry_after)
|
||||
return DeliveryResult(success=False, status="retry_after")
|
||||
except Exception as exc:
|
||||
logger.error("Telegram delivery error for %s: %s", telegram_id, exc)
|
||||
return DeliveryResult(success=False, status="unknown_error")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await self.unblock_user(session, telegram_id)
|
||||
|
||||
return DeliveryResult(success=True, telegram_message=telegram_message)
|
||||
|
||||
def classify_bad_request(self, exc: TelegramBadRequest) -> Optional[str]:
|
||||
error_text = str(exc).lower()
|
||||
for marker, error_type in self._BAD_REQUEST_ERROR_TYPES.items():
|
||||
if marker in error_text:
|
||||
return error_type
|
||||
return None
|
||||
|
||||
async def _mark_unavailable(
|
||||
self,
|
||||
telegram_id: int,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
async with async_session_maker() as session:
|
||||
await self.mark_user_blocked(session, telegram_id, error_type, error_message)
|
||||
|
||||
|
||||
delivery_service = DeliveryService()
|
||||
221
src/core/emoji_mapping_service.py
Normal file
221
src/core/emoji_mapping_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Сервис для управления маппингом кастомных эмодзи"""
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
from src.core.models import EmojiMapping, User
|
||||
|
||||
|
||||
class EmojiMappingService:
|
||||
"""Служба для управления маппингом эмодзи и их ID"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def register_emoji(
|
||||
self,
|
||||
emoji_text: str,
|
||||
emoji_id: str,
|
||||
admin_id: int,
|
||||
description: Optional[str] = None
|
||||
) -> EmojiMapping:
|
||||
"""
|
||||
Зарегистрировать новый эмодзи с его ID от Telegram
|
||||
|
||||
Args:
|
||||
emoji_text: Сам эмодзи символ (например, '🎲')
|
||||
emoji_id: telegram_emoji_id от Telegram API
|
||||
admin_id: ID админа, который добавил эмодзи
|
||||
description: Описание назначения этого эмодзи
|
||||
|
||||
Returns:
|
||||
Созданный объект EmojiMapping
|
||||
"""
|
||||
emoji = EmojiMapping(
|
||||
emoji_text=emoji_text,
|
||||
emoji_id=emoji_id,
|
||||
admin_id=admin_id,
|
||||
description=description,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
self.session.add(emoji)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(emoji)
|
||||
return emoji
|
||||
|
||||
async def get_emoji_by_text(self, emoji_text: str, admin_id: Optional[int] = None) -> Optional[EmojiMapping]:
|
||||
"""
|
||||
Получить маппинг эмодзи по его текстовому значению
|
||||
|
||||
Args:
|
||||
emoji_text: Текст эмодзи
|
||||
admin_id: Опционально - ID админа для фильтрации
|
||||
|
||||
Returns:
|
||||
EmojiMapping объект или None
|
||||
"""
|
||||
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
|
||||
if admin_id:
|
||||
query = query.where(EmojiMapping.admin_id == admin_id)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_emoji_by_id(self, emoji_id: str) -> Optional[EmojiMapping]:
|
||||
"""
|
||||
Получить маппинг эмодзи по его emoji_id
|
||||
|
||||
Args:
|
||||
emoji_id: telegram_emoji_id
|
||||
|
||||
Returns:
|
||||
EmojiMapping объект или None
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_all_emoji_by_admin(self, admin_id: int) -> List[EmojiMapping]:
|
||||
"""
|
||||
Получить все эмодзи, добавленные конкретным админом
|
||||
|
||||
Args:
|
||||
admin_id: ID админа
|
||||
|
||||
Returns:
|
||||
Список EmojiMapping объектов
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).where(EmojiMapping.admin_id == admin_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_emojis(self) -> List[EmojiMapping]:
|
||||
"""Получить все зарегистрированные эмодзи"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_emoji(self, emoji_id: str) -> bool:
|
||||
"""
|
||||
Удалить эмодзи маппинг
|
||||
|
||||
Args:
|
||||
emoji_id: telegram_emoji_id
|
||||
|
||||
Returns:
|
||||
True если удален, False если не найден
|
||||
"""
|
||||
emoji = await self.get_emoji_by_id(emoji_id)
|
||||
if emoji:
|
||||
await self.session.delete(emoji)
|
||||
await self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def update_last_used(self, emoji_id: str) -> bool:
|
||||
"""
|
||||
Обновить время последнего использования эмодзи
|
||||
|
||||
Args:
|
||||
emoji_id: telegram_emoji_id
|
||||
|
||||
Returns:
|
||||
True если обновлен, False если не найден
|
||||
"""
|
||||
await self.session.execute(
|
||||
update(EmojiMapping)
|
||||
.where(EmojiMapping.emoji_id == emoji_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def replace_emojis_in_text(self, text: str) -> str:
|
||||
"""
|
||||
Заменить все известные эмодзи на их emoji_id в тексте
|
||||
|
||||
Это используется перед отправкой сообщения в Telegram,
|
||||
чтобы эмодзи выглядели так же, как их отправил админ
|
||||
|
||||
Args:
|
||||
text: Исходный текст с эмодзи
|
||||
|
||||
Returns:
|
||||
Текст с заменой эмодзи на emoji_id
|
||||
"""
|
||||
# Получаем все эмодзи маппинги
|
||||
emojis = await self.get_all_emojis()
|
||||
|
||||
# Заменяем каждый эмодзи на его emoji_id
|
||||
for emoji in emojis:
|
||||
# Экранируем специальные символы если нужно
|
||||
if emoji.emoji_text in text:
|
||||
# Замена с сохранением контекста - оборачиваем в специальные маркеры
|
||||
# Это позволит потом распознать что это эмодзи ID а не обычный текст
|
||||
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
|
||||
|
||||
return text
|
||||
|
||||
async def restore_emojis_in_text(self, text: str) -> str:
|
||||
"""
|
||||
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
|
||||
|
||||
Args:
|
||||
text: Текст с emoji_id маркерами (|emoji_id|)
|
||||
|
||||
Returns:
|
||||
Текст с восстановленными эмодзи
|
||||
"""
|
||||
# Получаем все эмодзи маппинги
|
||||
emojis = await self.get_all_emojis()
|
||||
|
||||
# Восстанавливаем каждый эмодзи из его ID
|
||||
for emoji in emojis:
|
||||
if f"|{emoji.emoji_id}|" in text:
|
||||
text = text.replace(f"|{emoji.emoji_id}|", emoji.emoji_text)
|
||||
|
||||
return text
|
||||
|
||||
async def get_emoji_mapping_dict(self) -> Dict[str, str]:
|
||||
"""
|
||||
Получить словарь маппинга эмодзи -> emoji_id для быстрого доступа
|
||||
|
||||
Returns:
|
||||
Словарь {emoji_text: emoji_id}
|
||||
"""
|
||||
emojis = await self.get_all_emojis()
|
||||
return {emoji.emoji_text: emoji.emoji_id for emoji in emojis}
|
||||
|
||||
async def bulk_register_emojis(self, emojis_data: List[Dict]) -> List[EmojiMapping]:
|
||||
"""
|
||||
Зарегистрировать несколько эмодзи сразу
|
||||
|
||||
Args:
|
||||
emojis_data: Список со структурой [
|
||||
{
|
||||
'emoji_text': '🎲',
|
||||
'emoji_id': 'some_id',
|
||||
'admin_id': 123,
|
||||
'description': 'Для лотереи'
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Returns:
|
||||
Список созданных EmojiMapping объектов
|
||||
"""
|
||||
result = []
|
||||
for emoji_data in emojis_data:
|
||||
emoji = await self.register_emoji(
|
||||
emoji_text=emoji_data['emoji_text'],
|
||||
emoji_id=emoji_data['emoji_id'],
|
||||
admin_id=emoji_data['admin_id'],
|
||||
description=emoji_data.get('description')
|
||||
)
|
||||
result.append(emoji)
|
||||
return result
|
||||
61
src/core/emoji_message_helper.py
Normal file
61
src/core/emoji_message_helper.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Утилиты для автоматической замены эмодзи на emoji_id при отправке сообщений
|
||||
"""
|
||||
from typing import Optional
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .emoji_mapping_service import EmojiMappingService
|
||||
|
||||
|
||||
class EmojiMessageHelper:
|
||||
"""Помощник для работы с эмодзи в сообщениях"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.service = EmojiMappingService(session)
|
||||
|
||||
async def process_text_before_send(self, text: str) -> str:
|
||||
"""
|
||||
Обработать текст перед отправкой - заменить эмодзи на их ID
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Обработанный текст с заменой эмодзи на ID
|
||||
"""
|
||||
return await self.service.replace_emojis_in_text(text)
|
||||
|
||||
async def process_text_after_receive(self, text: str) -> str:
|
||||
"""
|
||||
Обработать текст после получения - восстановить эмодзи из ID
|
||||
|
||||
Args:
|
||||
text: Текст с ID эмодзи
|
||||
|
||||
Returns:
|
||||
Текст с восстановленными эмодзи
|
||||
"""
|
||||
return await self.service.restore_emojis_in_text(text)
|
||||
|
||||
|
||||
async def get_emoji_aware_text(session: AsyncSession, text: str) -> str:
|
||||
"""
|
||||
Удобная функция для получения эмодзи-оптимизированного текста
|
||||
|
||||
Заменяет все известные эмодзи на их telegram_emoji_id для правильного отображения
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
Текст с замененными эмодзи на их ID
|
||||
|
||||
Example:
|
||||
>>> text = "🎲 Выиграли! 🏆"
|
||||
>>> processed = await get_emoji_aware_text(session, text)
|
||||
>>> await message.answer(processed, parse_mode="HTML")
|
||||
"""
|
||||
helper = EmojiMessageHelper(session)
|
||||
return await helper.process_text_before_send(text)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from .database import Base
|
||||
@@ -8,6 +8,9 @@ import secrets
|
||||
class User(Base):
|
||||
"""Модель пользователя с регистрацией"""
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
Index("ix_users_registered_activity", "is_registered", "last_activity"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
||||
@@ -114,6 +117,10 @@ class Lottery(Base):
|
||||
class Participation(Base):
|
||||
"""Модель участия в розыгрыше"""
|
||||
__tablename__ = "participations"
|
||||
__table_args__ = (
|
||||
Index("ix_participations_lottery_account", "lottery_id", "account_number"),
|
||||
Index("ix_participations_user_created", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
@@ -136,6 +143,10 @@ class Participation(Base):
|
||||
class Winner(Base):
|
||||
"""Модель победителя розыгрыша"""
|
||||
__tablename__ = "winners"
|
||||
__table_args__ = (
|
||||
Index("ix_winners_lottery_place", "lottery_id", "place"),
|
||||
Index("ix_winners_user_id", "user_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||
@@ -271,6 +282,9 @@ class BroadcastChannel(Base):
|
||||
class BlockedUser(Base):
|
||||
"""Пользователи, которые заблокировали бота или недоступны"""
|
||||
__tablename__ = "blocked_users"
|
||||
__table_args__ = (
|
||||
Index("ix_blocked_users_lookup", "telegram_id", "error_type", "is_active"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)
|
||||
@@ -313,3 +327,25 @@ 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]}...)>"
|
||||
|
||||
93
src/core/premium_emoji.py
Normal file
93
src/core/premium_emoji.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
|
||||
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
|
||||
|
||||
Для использования премиум эмодзи:
|
||||
1. Бот должен быть создан с премиум аккаунта
|
||||
2. Использовать эмодзи напрямую в тексте сообщений
|
||||
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from aiogram.types import MessageEntity, TextQuote
|
||||
from aiogram.enums import MessageEntityType
|
||||
|
||||
|
||||
class PremiumEmojiConfig:
|
||||
"""Конфигурация поддержки премиум эмодзи"""
|
||||
|
||||
# Флаг, что бот может использовать премиум эмодзи
|
||||
SUPPORTS_PREMIUM_EMOJI = True
|
||||
|
||||
# Стандартные parse_mode для автоматической поддержки эмодзи
|
||||
DEFAULT_PARSE_MODE = "HTML" # Поддерживает эмодзи лучше чем Markdown
|
||||
|
||||
# Премиум эмодзи которые используются в приложении
|
||||
PREMIUM_EMOJIS = {
|
||||
# Розыгрыши
|
||||
"🎲_premium": "🎲", # Если есть премиум версия
|
||||
"🏆_premium": "🏆",
|
||||
"🎯_premium": "🎯",
|
||||
|
||||
# Логины
|
||||
"📱_premium": "📱",
|
||||
"🔐_premium": "🔐",
|
||||
|
||||
# Статусы
|
||||
"✅_premium": "✅",
|
||||
"❌_premium": "❌",
|
||||
"⏸_premium": "⏸️",
|
||||
}
|
||||
|
||||
|
||||
def supports_premium_emoji() -> bool:
|
||||
"""Проверить поддерживает ли бот премиум эмодзи"""
|
||||
return PremiumEmojiConfig.SUPPORTS_PREMIUM_EMOJI
|
||||
|
||||
|
||||
def get_parse_mode() -> str:
|
||||
"""Получить оптимальный parse_mode для поддержки эмодзи"""
|
||||
return PremiumEmojiConfig.DEFAULT_PARSE_MODE
|
||||
|
||||
|
||||
def ensure_emoji_support(text: str) -> str:
|
||||
"""
|
||||
Убедиться что текст может быть отправлен с эмодзи
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Обработанный текст с поддержкой эмодзи
|
||||
"""
|
||||
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
|
||||
# Эта функция может быть расширена для дополнительной обработки если нужно
|
||||
return text
|
||||
|
||||
|
||||
async def send_message_with_emoji(
|
||||
send_func,
|
||||
text: str,
|
||||
parse_mode: Optional[str] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Отправить сообщение с поддержкой премиум эмодзи
|
||||
|
||||
Args:
|
||||
send_func: Функция отправки (message.answer, callback.message.edit_text и т.д.)
|
||||
text: Текст сообщения
|
||||
parse_mode: Parse mode (если None, использует default)
|
||||
**kwargs: Дополнительные параметры
|
||||
|
||||
Returns:
|
||||
Результат отправки сообщения
|
||||
"""
|
||||
if parse_mode is None:
|
||||
parse_mode = get_parse_mode()
|
||||
|
||||
# Убедиться что текст может содержать эмодзи
|
||||
text = ensure_emoji_support(text)
|
||||
|
||||
# Отправить сообщение
|
||||
return await send_func(text, parse_mode=parse_mode, **kwargs)
|
||||
@@ -5,6 +5,7 @@ from .models import User, Account, Winner, WinnerVerification
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import secrets
|
||||
from ..utils.account_utils import format_account_number
|
||||
|
||||
|
||||
class RegistrationService:
|
||||
@@ -78,6 +79,10 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Account:
|
||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
raise ValueError(f"Некорректный формат счета: {account_number}")
|
||||
|
||||
# Находим владельца по клубной карте
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
@@ -89,14 +94,14 @@ class AccountService:
|
||||
|
||||
# Проверяем, не существует ли уже такой счет
|
||||
existing = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError(f"Счет {account_number} уже существует")
|
||||
raise ValueError(f"Счет {formatted_account} уже существует")
|
||||
|
||||
# Создаем счет
|
||||
account = Account(
|
||||
account_number=account_number,
|
||||
account_number=formatted_account,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
)
|
||||
@@ -112,10 +117,14 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Optional[User]:
|
||||
"""Найти владельца счета"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(Account).where(
|
||||
and_(
|
||||
Account.account_number == account_number,
|
||||
Account.account_number == formatted_account,
|
||||
Account.is_active == True
|
||||
)
|
||||
)
|
||||
@@ -150,8 +159,12 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> bool:
|
||||
"""Деактивировать счет"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
return False
|
||||
|
||||
result = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy import select, update, delete, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from .models import User, Lottery, Participation, Winner, Account
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..utils.account_utils import validate_account_number, format_account_number
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class UserService:
|
||||
@@ -129,27 +130,39 @@ class UserService:
|
||||
|
||||
@staticmethod
|
||||
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
|
||||
"""Установить номер клиентского счета пользователю"""
|
||||
"""Привязать номер клиентского счета к пользователю.
|
||||
|
||||
Исторически номер счета хранился в User.account_number. В актуальной
|
||||
схеме счета живут в отдельной таблице Account, поэтому метод оставлен
|
||||
как совместимый фасад для старых обработчиков и тестов.
|
||||
"""
|
||||
# Валидируем и форматируем номер
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
return False
|
||||
|
||||
# Проверяем уникальность номера
|
||||
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
existing = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False # Номер уже занят
|
||||
|
||||
# Обновляем пользователя
|
||||
result = await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
.values(account_number=formatted_number)
|
||||
select(Account).where(Account.account_number == formatted_number)
|
||||
)
|
||||
account = existing.scalar_one_or_none()
|
||||
if account:
|
||||
if account.owner_id != user.id:
|
||||
return False
|
||||
account.is_active = True
|
||||
else:
|
||||
session.add(Account(
|
||||
account_number=formatted_number,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
))
|
||||
|
||||
user.account_number = formatted_number
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
|
||||
@@ -157,6 +170,22 @@ class UserService:
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(User, Account.account_number)
|
||||
.join(Account, Account.owner_id == User.id)
|
||||
.where(
|
||||
Account.account_number == formatted_number,
|
||||
Account.is_active == True
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
user, account = row
|
||||
user.account_number = account
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||
@@ -174,12 +203,6 @@ class UserService:
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
|
||||
"""Поиск пользователей по части номера счета"""
|
||||
@@ -189,11 +212,17 @@ class UserService:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.account_number.like(f'%{clean_pattern}%')
|
||||
).limit(20)
|
||||
select(User, Account.account_number)
|
||||
.join(Account, Account.owner_id == User.id)
|
||||
.where(Account.account_number.like(f'%{clean_pattern}%'))
|
||||
.limit(20)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
users = []
|
||||
for user, account_number in result.all():
|
||||
user.account_number = account_number
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
|
||||
class LotteryService:
|
||||
@@ -307,15 +336,25 @@ class LotteryService:
|
||||
participants = []
|
||||
for p in lottery.participations:
|
||||
if p.user:
|
||||
participants.append(p.user)
|
||||
participants.append(SimpleNamespace(
|
||||
id=p.user.id,
|
||||
telegram_id=p.user.telegram_id,
|
||||
username=p.user.username,
|
||||
first_name=p.user.first_name,
|
||||
last_name=p.user.last_name,
|
||||
account_number=p.account_number
|
||||
))
|
||||
else:
|
||||
# Создаем временный объект для участников без пользователя
|
||||
# Храним только номер счета
|
||||
participants.append(type('obj', (object,), {
|
||||
'id': None,
|
||||
'telegram_id': None,
|
||||
'account_number': p.account_number
|
||||
})())
|
||||
participants.append(SimpleNamespace(
|
||||
id=None,
|
||||
telegram_id=None,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
account_number=p.account_number
|
||||
))
|
||||
|
||||
logger.info(f"conduct_draw: участников {len(participants)}")
|
||||
if not participants:
|
||||
@@ -503,13 +542,22 @@ class ParticipationService:
|
||||
@staticmethod
|
||||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||||
"""Получить участников розыгрыша"""
|
||||
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id)
|
||||
query = (
|
||||
select(Participation)
|
||||
.options(selectinload(Participation.user))
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
|
||||
if limit:
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
participants = []
|
||||
for participation in result.scalars().all():
|
||||
if participation.user:
|
||||
participation.user.account_number = participation.account_number
|
||||
participants.append(participation.user)
|
||||
return participants
|
||||
|
||||
@staticmethod
|
||||
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
|
||||
@@ -525,11 +573,9 @@ class ParticipationService:
|
||||
@staticmethod
|
||||
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
|
||||
"""Получить количество участников в розыгрыше"""
|
||||
result = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
return await session.scalar(
|
||||
select(func.count(Participation.id)).where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
return len(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
||||
@@ -595,9 +641,6 @@ class ParticipationService:
|
||||
@staticmethod
|
||||
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||
"""Массовое добавление участников по номерам счетов"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
results = {
|
||||
"added": 0,
|
||||
"skipped": 0,
|
||||
@@ -610,39 +653,30 @@ class ParticipationService:
|
||||
account_input = account_input.strip()
|
||||
if not account_input:
|
||||
continue
|
||||
|
||||
logger.info(f"DEBUG: Processing account_input={account_input!r}")
|
||||
|
||||
try:
|
||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||
parts = account_input.split()
|
||||
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
|
||||
|
||||
if len(parts) == 2:
|
||||
card_number = parts[0] # Номер клубной карты
|
||||
account_number = parts[1] # Номер счета
|
||||
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
|
||||
elif len(parts) == 1:
|
||||
# Если нет пробела, считаем что это просто номер счета
|
||||
card_number = None
|
||||
account_number = parts[0]
|
||||
logger.info(f"DEBUG: 1 part - account={account_number!r}")
|
||||
else:
|
||||
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат: {account_input}")
|
||||
continue
|
||||
|
||||
# Валидируем и форматируем номер счета
|
||||
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
|
||||
formatted_account = format_account_number(account_number)
|
||||
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
|
||||
|
||||
if not formatted_account:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||
logger.error(f"DEBUG: Format failed for {account_number!r}")
|
||||
continue
|
||||
|
||||
# Ищем владельца счёта через таблицу Account
|
||||
@@ -687,7 +721,6 @@ class ParticipationService:
|
||||
account_number=formatted_account
|
||||
)
|
||||
session.add(participation)
|
||||
await session.commit()
|
||||
|
||||
results["added"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
@@ -697,6 +730,9 @@ class ParticipationService:
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||
|
||||
if results["added"]:
|
||||
await session.commit()
|
||||
|
||||
return results
|
||||
|
||||
@@ -759,7 +795,6 @@ class ParticipationService:
|
||||
|
||||
if participation:
|
||||
await session.delete(participation)
|
||||
await session.commit()
|
||||
|
||||
results["removed"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
@@ -775,14 +810,15 @@ class ParticipationService:
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||
|
||||
if results["removed"]:
|
||||
await session.commit()
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||||
"""Статистика участника"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Количество участий
|
||||
participations_count = await session.scalar(
|
||||
select(func.count(Participation.id)).where(Participation.user_id == user_id)
|
||||
@@ -804,4 +840,42 @@ class ParticipationService:
|
||||
"participations_count": participations_count,
|
||||
"wins_count": wins_count,
|
||||
"last_participation": last_participation.created_at if last_participation else None
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_participant_stats_bulk(session: AsyncSession, user_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||
"""Статистика участников одним набором агрегирующих запросов."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
stats = {
|
||||
user_id: {
|
||||
"participations_count": 0,
|
||||
"wins_count": 0,
|
||||
"last_participation": None,
|
||||
}
|
||||
for user_id in user_ids
|
||||
}
|
||||
|
||||
participations = await session.execute(
|
||||
select(
|
||||
Participation.user_id,
|
||||
func.count(Participation.id),
|
||||
func.max(Participation.created_at),
|
||||
)
|
||||
.where(Participation.user_id.in_(user_ids))
|
||||
.group_by(Participation.user_id)
|
||||
)
|
||||
for user_id, count, last_participation in participations.all():
|
||||
stats[user_id]["participations_count"] = count
|
||||
stats[user_id]["last_participation"] = last_participation
|
||||
|
||||
wins = await session.execute(
|
||||
select(Winner.user_id, func.count(Winner.id))
|
||||
.where(Winner.user_id.in_(user_ids))
|
||||
.group_by(Winner.user_id)
|
||||
)
|
||||
for user_id, count in wins.all():
|
||||
stats[user_id]["wins_count"] = count
|
||||
|
||||
return stats
|
||||
|
||||
42
src/core/telegram_config.py
Normal file
42
src/core/telegram_config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Глобальная конфигурация для Telegram Bot API параметров
|
||||
Включая поддержку премиум эмодзи
|
||||
"""
|
||||
|
||||
# Parse mode для всех сообщений
|
||||
# HTML поддерживает премиум эмодзи лучше чем Markdown
|
||||
GLOBAL_PARSE_MODE = "HTML"
|
||||
|
||||
# Доступные parse modes
|
||||
PARSE_MODES = {
|
||||
"HTML": "HTML",
|
||||
"MARKDOWN": "Markdown",
|
||||
"NONE": None
|
||||
}
|
||||
|
||||
# Какой parse mode использовать для разных типов сообщений
|
||||
MESSAGE_PARSE_MODES = {
|
||||
"text_message": "HTML", # Обычные текстовые сообщения
|
||||
"inline_keyboard": "HTML", # С inline клавиатурой
|
||||
"reply_keyboard": "HTML", # С reply клавиатуре
|
||||
"edit_message": "HTML", # Редактирование сообщения
|
||||
"broadcast": "HTML", # Массовые рассылки
|
||||
"admin_broadcast": "HTML", # Административные рассылки
|
||||
}
|
||||
|
||||
def get_parse_mode(message_type: str = "text_message") -> str:
|
||||
"""
|
||||
Получить parse_mode для типа сообщения
|
||||
|
||||
Args:
|
||||
message_type: Тип сообщения (см. MESSAGE_PARSE_MODES)
|
||||
|
||||
Returns:
|
||||
Parse mode строка ("HTML", "Markdown", None)
|
||||
"""
|
||||
return MESSAGE_PARSE_MODES.get(message_type, GLOBAL_PARSE_MODE)
|
||||
|
||||
|
||||
def get_global_parse_mode() -> str:
|
||||
"""Получить глобальный parse mode"""
|
||||
return GLOBAL_PARSE_MODE
|
||||
@@ -6,7 +6,6 @@ import asyncio
|
||||
import json
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.services import LotteryService, ParticipationService
|
||||
from ..core.models import Lottery, User
|
||||
|
||||
async def conduct_lottery_draw(lottery_id: int):
|
||||
"""Проводим розыгрыш для указанного ID"""
|
||||
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
# Выводим список участников
|
||||
print("\n👥 Список участников:")
|
||||
for i, user in enumerate(participants, 1):
|
||||
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов']
|
||||
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
||||
username = f"@{user.username}" if user.username else "без username"
|
||||
print(f" {i}. {user.first_name or 'Без имени'} ({username})")
|
||||
|
||||
# Проводим розыгрыш
|
||||
print(f"\n🎲 Проводим розыгрыш...")
|
||||
@@ -48,14 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
|
||||
if winners:
|
||||
print(f"\n🎉 Победители определены:")
|
||||
for i, winner_data in enumerate(winners, 1):
|
||||
for place, winner_data in winners.items():
|
||||
user = winner_data['user']
|
||||
prize = winner_data['prize']
|
||||
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
||||
name = user.first_name or getattr(user, "account_number", None) or "Неизвестный участник"
|
||||
username = f" (@{user.username})" if getattr(user, 'username', None) else ""
|
||||
print(f" 🏆 {place} место: {name}{username}")
|
||||
print(f" 💎 Приз: {prize}")
|
||||
|
||||
# Обновляем статус розыгрыша
|
||||
await LotteryService.set_lottery_completed(session, lottery_id, True)
|
||||
await session.commit()
|
||||
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
||||
|
||||
else:
|
||||
@@ -99,4 +99,4 @@ async def main():
|
||||
print("❌ Неверный ID розыгрыша")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
import asyncio
|
||||
from ..core.database import async_session_maker, init_db
|
||||
from ..core.services import UserService, LotteryService
|
||||
from ..core.services import UserService, LotteryService, ParticipationService
|
||||
from ..utils.admin_utils import AdminUtils, ReportGenerator
|
||||
|
||||
|
||||
@@ -87,17 +87,17 @@ async def demo_admin_features():
|
||||
participants_added = 0
|
||||
for user in users:
|
||||
# В первый розыгрыш добавляем всех
|
||||
if await LotteryService.add_participant(session, lottery1.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery1.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# Во второй - половину
|
||||
if user.id % 2 == 0:
|
||||
if await LotteryService.add_participant(session, lottery2.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery2.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# В третий - треть
|
||||
if user.id % 3 == 0:
|
||||
if await LotteryService.add_participant(session, lottery3.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery3.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
print(f"✅ Добавлено {participants_added} участий")
|
||||
@@ -127,6 +127,7 @@ async def demo_admin_features():
|
||||
|
||||
# Первый розыгрыш
|
||||
results1 = await LotteryService.conduct_draw(session, lottery1.id)
|
||||
await session.commit()
|
||||
print(f" 🎉 {lottery1.title} - результаты:")
|
||||
for place, winner_info in results1.items():
|
||||
user = winner_info['user']
|
||||
@@ -135,6 +136,7 @@ async def demo_admin_features():
|
||||
|
||||
# Второй розыгрыш
|
||||
results2 = await LotteryService.conduct_draw(session, lottery2.id)
|
||||
await session.commit()
|
||||
print(f" 🚗 {lottery2.title} - результаты:")
|
||||
for place, winner_info in results2.items():
|
||||
user = winner_info['user']
|
||||
@@ -188,4 +190,4 @@ async def demo_admin_features():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demo_admin_features())
|
||||
asyncio.run(demo_admin_features())
|
||||
|
||||
@@ -19,6 +19,7 @@ async def conduct_simple_draw():
|
||||
try:
|
||||
# Проводим розыгрыш
|
||||
winners = await LotteryService.conduct_draw(session, lottery_id)
|
||||
await session.commit()
|
||||
|
||||
print(f"🎉 Розыгрыш проведен!")
|
||||
print(f"📊 Результат: {winners}")
|
||||
@@ -29,4 +30,4 @@ async def conduct_simple_draw():
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(conduct_simple_draw())
|
||||
asyncio.run(conduct_simple_draw())
|
||||
|
||||
@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
|
||||
from ..utils.account_utils import mask_account_number
|
||||
|
||||
|
||||
def _get_user_account_number(user: User) -> Optional[str]:
|
||||
"""Вернуть номер счета из совместимого поля или уже загруженной связи."""
|
||||
account_number = getattr(user, 'account_number', None)
|
||||
if account_number:
|
||||
return account_number
|
||||
|
||||
# Не инициируем lazy-load в async SQLAlchemy: берем только уже загруженную связь.
|
||||
accounts = getattr(user, '__dict__', {}).get('accounts') or []
|
||||
for account in accounts:
|
||||
if getattr(account, 'is_active', True) and getattr(account, 'account_number', None):
|
||||
return account.account_number
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
|
||||
"""
|
||||
Форматирует отображение победителя в зависимости от настроек розыгрыша
|
||||
@@ -33,15 +48,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
|
||||
|
||||
elif display_type == 'account_number':
|
||||
# Отображаем номер клиентского счета
|
||||
if not user.account_number:
|
||||
account_number = _get_user_account_number(user)
|
||||
if not account_number:
|
||||
return "Счёт не указан"
|
||||
|
||||
if show_sensitive_data:
|
||||
# Для админов показываем полный номер
|
||||
return f"Счёт: {user.account_number}"
|
||||
return f"Счёт: {account_number}"
|
||||
else:
|
||||
# Для публичного показа маскируем номер
|
||||
masked = mask_account_number(user.account_number, show_last_digits=4)
|
||||
masked = mask_account_number(account_number, show_last_digits=4)
|
||||
return f"Счёт: {masked}"
|
||||
|
||||
else:
|
||||
@@ -113,4 +129,4 @@ def validate_display_type(display_type: str) -> bool:
|
||||
Returns:
|
||||
bool: True если тип корректен
|
||||
"""
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
|
||||
@@ -15,7 +15,8 @@ class AccountParticipationService:
|
||||
async def add_account_to_lottery(
|
||||
session: AsyncSession,
|
||||
lottery_id: int,
|
||||
account_number: str
|
||||
account_number: str,
|
||||
commit: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Добавить счет в розыгрыш.
|
||||
@@ -91,7 +92,10 @@ class AccountParticipationService:
|
||||
account_id=account_record.id if account_record else None
|
||||
)
|
||||
session.add(participation)
|
||||
await session.commit()
|
||||
if commit:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.flush()
|
||||
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
return {
|
||||
@@ -120,7 +124,7 @@ class AccountParticipationService:
|
||||
|
||||
for account in account_numbers:
|
||||
result = await AccountParticipationService.add_account_to_lottery(
|
||||
session, lottery_id, account
|
||||
session, lottery_id, account, commit=False
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
@@ -132,6 +136,8 @@ class AccountParticipationService:
|
||||
results["skipped_accounts"].append(account)
|
||||
results["errors"].append(result["message"])
|
||||
results["details"].append(f"❌ {result['message']}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
return results
|
||||
|
||||
@@ -139,7 +145,8 @@ class AccountParticipationService:
|
||||
async def remove_account_from_lottery(
|
||||
session: AsyncSession,
|
||||
lottery_id: int,
|
||||
account_number: str
|
||||
account_number: str,
|
||||
commit: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Удалить счет из розыгрыша"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
@@ -164,7 +171,10 @@ class AccountParticipationService:
|
||||
}
|
||||
|
||||
await session.delete(participation)
|
||||
await session.commit()
|
||||
if commit:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.flush()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -216,13 +226,21 @@ class AccountParticipationService:
|
||||
pattern: Паттерн поиска (например "11-22" или "33")
|
||||
limit: Максимальное количество результатов
|
||||
"""
|
||||
# Получаем все счета розыгрыша
|
||||
all_accounts = await AccountParticipationService.get_lottery_accounts(
|
||||
session, lottery_id
|
||||
digits_pattern = ''.join(c for c in pattern if c.isdigit())
|
||||
if not digits_pattern:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(Participation.account_number)
|
||||
.where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_number.isnot(None),
|
||||
func.replace(Participation.account_number, '-', '').contains(digits_pattern),
|
||||
)
|
||||
.order_by(Participation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
# Ищем совпадения
|
||||
return search_accounts_by_pattern(pattern, all_accounts)[:limit]
|
||||
return [account for account in result.scalars().all() if account]
|
||||
|
||||
@staticmethod
|
||||
async def set_account_as_winner(
|
||||
|
||||
273
src/handlers/admin_emoji_handlers.py
Normal file
273
src/handlers/admin_emoji_handlers.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Хендлеры для управления кастомными эмодзи админом
|
||||
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||
"""
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.config import ADMIN_IDS
|
||||
from ..core.emoji_mapping_service import EmojiMappingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
class EmojiStates(StatesGroup):
|
||||
waiting_for_emoji = State()
|
||||
waiting_for_description = State()
|
||||
|
||||
|
||||
@router.message(Command("add_emoji"), StateFilter(None))
|
||||
async def add_emoji_start(message: Message, state: FSMContext):
|
||||
"""Начать процесс добавления нового эмодзи"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
|
||||
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
|
||||
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(EmojiStates.waiting_for_emoji)
|
||||
|
||||
|
||||
@router.message(EmojiStates.waiting_for_emoji)
|
||||
async def receive_emoji(message: Message, state: FSMContext):
|
||||
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
||||
# Проверяем что это именно тект сообщение с эмодзи
|
||||
if not message.text or len(message.text) > 10:
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
|
||||
)
|
||||
return
|
||||
|
||||
emoji_text = message.text.strip()
|
||||
|
||||
# Проверяем что хотя бы один символ это эмодзи
|
||||
has_emoji = any(ord(c) > 127 for c in emoji_text)
|
||||
if not has_emoji:
|
||||
await message.answer(
|
||||
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
|
||||
)
|
||||
return
|
||||
|
||||
# Извлекаем emoji_id из entities если это есть
|
||||
emoji_id = None
|
||||
|
||||
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
|
||||
if message.entities:
|
||||
for entity in message.entities:
|
||||
if entity.type == "custom_emoji":
|
||||
# Получаем text с этим entity
|
||||
emoji_id = entity.custom_emoji_id
|
||||
break
|
||||
|
||||
# Если нет custom_emoji entity, пробуем другой способ
|
||||
if not emoji_id:
|
||||
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
|
||||
# и смотрим entities
|
||||
try:
|
||||
# Отправляем сообщение с эмодзи обратно
|
||||
test_msg = await message.answer(
|
||||
f"Тестирую эмодзи: {emoji_text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
# Пытаемся получить emoji_id из реакции
|
||||
# В Telegram для premium emoji нужно обращаться к API
|
||||
# Но мы можем просто использовать сам emoji как ID - он уникален
|
||||
emoji_id = emoji_text
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing emoji: {e}")
|
||||
emoji_id = emoji_text
|
||||
|
||||
# Сохраняем в состояние
|
||||
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
|
||||
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
||||
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(EmojiStates.waiting_for_description)
|
||||
|
||||
|
||||
@router.message(EmojiStates.waiting_for_description)
|
||||
async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
"""Получить описание эмодзи и сохранить в БД"""
|
||||
if not message.text:
|
||||
await message.answer("❌ Пожалуйста, отправьте текстовое описание")
|
||||
return
|
||||
|
||||
description = message.text.strip()
|
||||
data = await state.get_data()
|
||||
emoji_text = data.get("emoji_text")
|
||||
emoji_id = data.get("emoji_id")
|
||||
|
||||
# Сохраняем в БД
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
|
||||
# Проверяем не существует ли уже такой эмодзи
|
||||
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
|
||||
if existing:
|
||||
await message.answer(
|
||||
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
|
||||
f"Описание: <code>{existing.description}</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
emoji_mapping = await emoji_service.register_emoji(
|
||||
emoji_text=emoji_text,
|
||||
emoji_id=emoji_id,
|
||||
admin_id=message.from_user.id,
|
||||
description=description
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
|
||||
f"Эмодзи: <code>{emoji_text}</code>\n"
|
||||
f"Описание: <code>{description}</code>\n"
|
||||
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
||||
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering emoji: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(Command("my_emojis"))
|
||||
async def list_my_emojis(message: Message):
|
||||
"""Показать все эмодзи, добавленные этим админом"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
|
||||
|
||||
if not emojis:
|
||||
await message.answer(
|
||||
"📭 Вы еще не добавили ни один эмодзи.\n\n"
|
||||
"Используйте /add_emoji чтобы добавить новый эмодзи"
|
||||
)
|
||||
return
|
||||
|
||||
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
|
||||
for emoji in emojis:
|
||||
text += (
|
||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
|
||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("all_emojis"))
|
||||
async def list_all_emojis(message: Message):
|
||||
"""Показать все зарегистрированные эмодзи (для всех админов)"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
emojis = await emoji_service.get_all_emojis()
|
||||
|
||||
if not emojis:
|
||||
await message.answer(
|
||||
"📭 Нет зарегистрированных эмодзи в системе"
|
||||
)
|
||||
return
|
||||
|
||||
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
|
||||
for emoji in emojis:
|
||||
text += (
|
||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
|
||||
f"(ID: {emoji.admin_id})\n"
|
||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("delete_emoji"))
|
||||
async def delete_emoji_start(message: Message, state: FSMContext):
|
||||
"""Удалить эмодзи"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
|
||||
|
||||
if not emojis:
|
||||
await message.answer(
|
||||
"📭 У вас нет зарегистрированных эмодзи"
|
||||
)
|
||||
return
|
||||
|
||||
# Создаем клавиатуру для выбора эмодзи
|
||||
buttons = []
|
||||
for emoji in emojis:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji.emoji_text} ({emoji.description})",
|
||||
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
||||
)
|
||||
])
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
await message.answer(
|
||||
"🗑️ Выберите эмодзи для удаления:",
|
||||
reply_markup=kb
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_emoji_"))
|
||||
async def delete_emoji_confirm(callback: CallbackQuery):
|
||||
"""Подтвердить удаление эмодзи"""
|
||||
emoji_id = callback.data.replace("delete_emoji_", "")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
emoji = await emoji_service.get_emoji_by_id(emoji_id)
|
||||
|
||||
if not emoji:
|
||||
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
|
||||
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
|
||||
return
|
||||
|
||||
success = await emoji_service.delete_emoji(emoji_id)
|
||||
if success:
|
||||
await callback.answer(
|
||||
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
|
||||
show_alert=True
|
||||
)
|
||||
await callback.message.delete()
|
||||
else:
|
||||
await callback.answer("❌ Ошибка при удалении эмодзи", show_alert=True)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Расширенная админ-панель для управления розыгрышами
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import (
|
||||
@@ -24,6 +25,107 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_users_export_file(users_data: list[dict]) -> bytes:
|
||||
from io import BytesIO
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Пользователи"
|
||||
|
||||
headers = [
|
||||
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
|
||||
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
|
||||
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
|
||||
]
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
for row_num, user in enumerate(users_data, 2):
|
||||
ws.cell(row=row_num, column=1, value=user["telegram_id"])
|
||||
ws.cell(row=row_num, column=2, value=user["username"] or '')
|
||||
ws.cell(row=row_num, column=3, value=user["first_name"] or '')
|
||||
ws.cell(row=row_num, column=4, value=user["last_name"] or '')
|
||||
ws.cell(row=row_num, column=5, value=user["nickname"] or '')
|
||||
ws.cell(row=row_num, column=6, value=user["phone"] or '')
|
||||
ws.cell(row=row_num, column=7, value=user["club_card_number"] or '')
|
||||
ws.cell(row=row_num, column=8, value='Да' if user["is_registered"] else 'Нет')
|
||||
ws.cell(row=row_num, column=9, value='Да' if user["is_admin"] else 'Нет')
|
||||
ws.cell(row=row_num, column=10, value=user["verification_code"] or '')
|
||||
ws.cell(row=row_num, column=11, value=user["created_at"] or '')
|
||||
ws.cell(row=row_num, column=12, value=user["last_activity"] or '')
|
||||
ws.cell(row=row_num, column=13, value='Да' if user["is_chat_banned"] else 'Нет')
|
||||
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
max_length = max(max_length, len(str(cell.value)))
|
||||
except Exception:
|
||||
pass
|
||||
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||||
|
||||
excel_file = BytesIO()
|
||||
wb.save(excel_file)
|
||||
return excel_file.getvalue()
|
||||
|
||||
|
||||
def _parse_users_xlsx(file_bytes: bytes) -> list[dict]:
|
||||
from io import BytesIO
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(BytesIO(file_bytes), read_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
|
||||
if len(rows) < 2:
|
||||
return []
|
||||
|
||||
headers = [h if h else '' for h in rows[0]]
|
||||
telegram_id_idx = headers.index('Telegram ID')
|
||||
field_mapping = {
|
||||
'Username': 'username',
|
||||
'Имя': 'first_name',
|
||||
'Фамилия': 'last_name',
|
||||
'Никнейм': 'nickname',
|
||||
'Телефон': 'phone',
|
||||
'Клубная карта': 'club_card_number',
|
||||
'Зарегистрирован': 'is_registered',
|
||||
'Код верификации': 'verification_code'
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for row in rows[1:]:
|
||||
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
|
||||
continue
|
||||
|
||||
user_dict = {'telegram_id': row[telegram_id_idx]}
|
||||
for header_name, field_name in field_mapping.items():
|
||||
try:
|
||||
idx = headers.index(header_name)
|
||||
if idx < len(row):
|
||||
value = row[idx]
|
||||
user_dict[field_name] = (
|
||||
value in ['Да', 'Yes', 'True', True, 1]
|
||||
if field_name == 'is_registered'
|
||||
else value if value else None
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
user_dict[field_name] = None
|
||||
users_data.append(user_dict)
|
||||
|
||||
return users_data
|
||||
|
||||
|
||||
async def safe_edit_message(
|
||||
callback: CallbackQuery,
|
||||
text: str,
|
||||
@@ -179,7 +281,8 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"),
|
||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")],
|
||||
[InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")],
|
||||
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report")],
|
||||
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report"),
|
||||
InlineKeyboardButton(text="🏦 Счета", callback_data="admin_participants_accounts_report")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
@@ -487,7 +590,7 @@ async def confirm_create_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎰 К управлению розыгрышами", callback_data="admin_lotteries")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
])
|
||||
)
|
||||
|
||||
@@ -979,12 +1082,10 @@ async def list_all_participants(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
users = await UserService.get_all_users(session, limit=50)
|
||||
|
||||
# Получаем статистику для каждого пользователя
|
||||
user_stats = []
|
||||
for user in users:
|
||||
stats = await ParticipationService.get_participant_stats(session, user.id)
|
||||
user_stats.append((user, stats))
|
||||
stats_by_user = await ParticipationService.get_participant_stats_bulk(
|
||||
session, [user.id for user in users]
|
||||
)
|
||||
user_stats = [(user, stats_by_user[user.id]) for user in users]
|
||||
|
||||
if not user_stats:
|
||||
await callback.message.edit_text(
|
||||
@@ -1115,6 +1216,9 @@ async def export_participants_data(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
users = await UserService.get_all_users(session)
|
||||
stats_by_user = await ParticipationService.get_participant_stats_bulk(
|
||||
session, [user.id for user in users]
|
||||
)
|
||||
|
||||
export_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
@@ -1123,7 +1227,7 @@ async def export_participants_data(callback: CallbackQuery):
|
||||
}
|
||||
|
||||
for user in users:
|
||||
stats = await ParticipationService.get_participant_stats(session, user.id)
|
||||
stats = stats_by_user[user.id]
|
||||
user_data = {
|
||||
"id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
@@ -1191,12 +1295,10 @@ async def process_search_participants(message: Message, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
users = await UserService.search_users(session, search_term)
|
||||
|
||||
# Получаем статистику для найденных пользователей
|
||||
user_stats = []
|
||||
for user in users:
|
||||
stats = await ParticipationService.get_participant_stats(session, user.id)
|
||||
user_stats.append((user, stats))
|
||||
stats_by_user = await ParticipationService.get_participant_stats_bulk(
|
||||
session, [user.id for user in users]
|
||||
)
|
||||
user_stats = [(user, stats_by_user[user.id]) for user in users]
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -2359,16 +2461,16 @@ async def show_participants_by_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_participants_report")
|
||||
async def show_participants_report(callback: CallbackQuery):
|
||||
"""Отчет по участникам"""
|
||||
@admin_router.callback_query(F.data == "admin_participants_accounts_report")
|
||||
async def show_participants_accounts_report(callback: CallbackQuery):
|
||||
"""Отчет по участникам и привязанным счетам"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import func, select
|
||||
from ..core.models import User, Participation, Lottery
|
||||
from ..core.models import User, Participation, Account
|
||||
|
||||
# Общая статистика по участникам
|
||||
total_participants = await session.scalar(
|
||||
@@ -2378,32 +2480,33 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
total_participations = await session.scalar(select(func.count(Participation.id)))
|
||||
total_users = await session.scalar(select(func.count(User.id)))
|
||||
|
||||
# Топ активных участников
|
||||
top_participants = await session.execute(
|
||||
select(
|
||||
User.first_name,
|
||||
User.username,
|
||||
User.account_number,
|
||||
func.count(Participation.id).label('participations')
|
||||
func.min(Account.account_number).label('account_sample'),
|
||||
func.count(func.distinct(Participation.id)).label('participations')
|
||||
)
|
||||
.select_from(User)
|
||||
.join(Participation)
|
||||
.outerjoin(Account, Account.owner_id == User.id)
|
||||
.group_by(User.id)
|
||||
.order_by(func.count(Participation.id).desc())
|
||||
.order_by(func.count(func.distinct(Participation.id)).desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_participants = top_participants.fetchall()
|
||||
|
||||
# Участники с аккаунтами vs без
|
||||
users_with_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.isnot(None))
|
||||
select(func.count(func.distinct(Account.owner_id)))
|
||||
)
|
||||
|
||||
users_without_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.is_(None))
|
||||
)
|
||||
users_without_accounts = max((total_users or 0) - (users_with_accounts or 0), 0)
|
||||
|
||||
text = "📈 Отчет по участникам\n\n"
|
||||
text = "📈 Отчет по участникам и счетам\n\n"
|
||||
text += f"👥 Всего уникальных участников: {total_participants}\n"
|
||||
text += f"📊 Всего участий: {total_participations}\n"
|
||||
text += f"🏦 С номерами счетов: {users_with_accounts}\n"
|
||||
@@ -2420,7 +2523,7 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_report")],
|
||||
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_accounts_report")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")]
|
||||
])
|
||||
)
|
||||
@@ -3554,20 +3657,10 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await session.commit()
|
||||
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
|
||||
|
||||
# Отправляем уведомления победителям
|
||||
from ..utils.notifications import notify_winners_async
|
||||
try:
|
||||
await notify_winners_async(callback.bot, session, lottery_id)
|
||||
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
|
||||
# Отправляем результаты розыгрыша всем участникам (кроме победителей)
|
||||
try:
|
||||
await _notify_all_participants_about_results(callback.bot, session, lottery_id, winners_dict)
|
||||
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при рассылке результатов: {e}")
|
||||
asyncio.create_task(
|
||||
_send_draw_notifications_background(callback.bot, lottery_id, winners_dict)
|
||||
)
|
||||
logger.info(f"Фоновая рассылка итогов запущена для розыгрыша {lottery_id}")
|
||||
|
||||
# Получаем победителей из базы
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
@@ -4482,6 +4575,24 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок")
|
||||
|
||||
|
||||
async def _send_draw_notifications_background(bot, lottery_id: int, winners_dict: dict):
|
||||
"""Отправить уведомления по итогам розыгрыша вне callback handler."""
|
||||
async with async_session_maker() as session:
|
||||
from ..utils.notifications import notify_winners_async
|
||||
|
||||
try:
|
||||
await notify_winners_async(bot, session, lottery_id)
|
||||
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
await _notify_all_participants_about_results(bot, session, lottery_id, winners_dict)
|
||||
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при рассылке результатов: {e}", exc_info=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ
|
||||
# ============================================================================
|
||||
@@ -4496,87 +4607,45 @@ async def admin_export_users(callback: CallbackQuery):
|
||||
await callback.answer("⏳ Формирую файл...", show_alert=False)
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from io import BytesIO
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех пользователей
|
||||
all_users = await UserService.get_all_users(session)
|
||||
|
||||
# Создаем Excel файл
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Пользователи"
|
||||
|
||||
# Заголовки
|
||||
headers = [
|
||||
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
|
||||
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
|
||||
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
|
||||
]
|
||||
|
||||
# Стиль для заголовков
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# Данные пользователей
|
||||
for row_num, user in enumerate(all_users, 2):
|
||||
ws.cell(row=row_num, column=1, value=user.telegram_id)
|
||||
ws.cell(row=row_num, column=2, value=user.username or '')
|
||||
ws.cell(row=row_num, column=3, value=user.first_name or '')
|
||||
ws.cell(row=row_num, column=4, value=user.last_name or '')
|
||||
ws.cell(row=row_num, column=5, value=user.nickname or '')
|
||||
ws.cell(row=row_num, column=6, value=user.phone or '')
|
||||
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
|
||||
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
|
||||
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
|
||||
ws.cell(row=row_num, column=10, value=user.verification_code or '')
|
||||
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
|
||||
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
|
||||
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
|
||||
|
||||
# Автоподбор ширины колонок
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# Сохраняем в BytesIO
|
||||
excel_file = BytesIO()
|
||||
wb.save(excel_file)
|
||||
excel_file.seek(0)
|
||||
|
||||
# Отправляем файл
|
||||
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file = BufferedInputFile(excel_file.read(), filename=filename)
|
||||
|
||||
registered_count = len([u for u in all_users if u.is_registered])
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=file,
|
||||
caption=(
|
||||
f"📥 <b>Экспорт пользователей</b>\n\n"
|
||||
f"📊 Всего пользователей: {len(all_users)}\n"
|
||||
f"✅ Зарегистрировано: {registered_count}\n"
|
||||
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
users_data = [
|
||||
{
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"nickname": user.nickname,
|
||||
"phone": user.phone,
|
||||
"club_card_number": user.club_card_number,
|
||||
"is_registered": user.is_registered,
|
||||
"is_admin": user.is_admin,
|
||||
"verification_code": user.verification_code,
|
||||
"created_at": user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '',
|
||||
"last_activity": user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '',
|
||||
"is_chat_banned": user.is_chat_banned,
|
||||
}
|
||||
for user in all_users
|
||||
]
|
||||
|
||||
excel_bytes = await asyncio.to_thread(_build_users_export_file, users_data)
|
||||
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file = BufferedInputFile(excel_bytes, filename=filename)
|
||||
registered_count = sum(1 for user in all_users if user.is_registered)
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=file,
|
||||
caption=(
|
||||
f"📥 <b>Экспорт пользователей</b>\n\n"
|
||||
f"📊 Всего пользователей: {len(all_users)}\n"
|
||||
f"✅ Зарегистрировано: {registered_count}\n"
|
||||
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Файл отправлен", show_alert=False)
|
||||
except Exception as e:
|
||||
@@ -4631,69 +4700,21 @@ async def admin_import_users_process(message: Message, state: FSMContext):
|
||||
status_msg = await message.answer("⏳ Загружаю файл...")
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
from io import BytesIO
|
||||
|
||||
# Скачиваем файл
|
||||
file = await message.bot.get_file(message.document.file_id)
|
||||
file_content = await message.bot.download_file(file.file_path)
|
||||
|
||||
# Читаем Excel файл
|
||||
excel_file = BytesIO(file_content.read())
|
||||
wb = load_workbook(excel_file, read_only=True)
|
||||
ws = wb.active
|
||||
|
||||
# Читаем данные
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
|
||||
if len(rows) < 2:
|
||||
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Первая строка - заголовки
|
||||
headers = [h if h else '' for h in rows[0]]
|
||||
|
||||
# Находим индекс колонки Telegram ID
|
||||
|
||||
try:
|
||||
telegram_id_idx = headers.index('Telegram ID')
|
||||
users_data = await asyncio.to_thread(_parse_users_xlsx, file_content.read())
|
||||
except ValueError:
|
||||
await status_msg.edit_text("❌ Не найдена обязательная колонка 'Telegram ID'.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Создаем маппинг индексов для других полей
|
||||
field_mapping = {
|
||||
'Username': 'username',
|
||||
'Имя': 'first_name',
|
||||
'Фамилия': 'last_name',
|
||||
'Никнейм': 'nickname',
|
||||
'Телефон': 'phone',
|
||||
'Клубная карта': 'club_card_number',
|
||||
'Зарегистрирован': 'is_registered',
|
||||
'Код верификации': 'verification_code'
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for row in rows[1:]: # Пропускаем заголовки
|
||||
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
|
||||
continue
|
||||
|
||||
user_dict = {'telegram_id': row[telegram_id_idx]}
|
||||
|
||||
for header_name, field_name in field_mapping.items():
|
||||
try:
|
||||
idx = headers.index(header_name)
|
||||
if idx < len(row):
|
||||
value = row[idx]
|
||||
if field_name == 'is_registered':
|
||||
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
|
||||
else:
|
||||
user_dict[field_name] = value if value else None
|
||||
except (ValueError, IndexError):
|
||||
user_dict[field_name] = None
|
||||
|
||||
users_data.append(user_dict)
|
||||
|
||||
if not users_data:
|
||||
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await status_msg.edit_text(
|
||||
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
|
||||
@@ -4702,26 +4723,39 @@ async def admin_import_users_process(message: Message, state: FSMContext):
|
||||
|
||||
# Импортируем пользователей
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
error_count = 0
|
||||
|
||||
|
||||
normalized_users = []
|
||||
for user_data in users_data:
|
||||
telegram_id = user_data.get('telegram_id')
|
||||
if not telegram_id:
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
user_data['telegram_id'] = int(telegram_id)
|
||||
except (ValueError, TypeError):
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
normalized_users.append(user_data)
|
||||
|
||||
existing_result = await session.execute(
|
||||
select(User).where(User.telegram_id.in_([u['telegram_id'] for u in normalized_users]))
|
||||
)
|
||||
existing_by_tg_id = {
|
||||
user.telegram_id: user
|
||||
for user in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
for user_data in normalized_users:
|
||||
try:
|
||||
telegram_id = user_data.get('telegram_id')
|
||||
if not telegram_id:
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Преобразуем telegram_id в int если это строка
|
||||
try:
|
||||
telegram_id = int(telegram_id)
|
||||
except (ValueError, TypeError):
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Ищем существующего пользователя
|
||||
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
existing_user = existing_by_tg_id.get(telegram_id)
|
||||
|
||||
if existing_user:
|
||||
# Обновляем существующего
|
||||
@@ -4827,7 +4861,7 @@ async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext):
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")],
|
||||
[InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")],
|
||||
[InlineKeyboardButton(text="<EFBFBD> Статистика рассылок", callback_data="admin_broadcast_stats")],
|
||||
[InlineKeyboardButton(text="📊 Статистика рассылок", callback_data="admin_broadcast_stats")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
@@ -6085,4 +6119,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
# Экспорт роутера
|
||||
__all__ = ['admin_router']
|
||||
__all__ = ['admin_router']
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Обработчики пользовательских сообщений в чате"""
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -21,8 +22,11 @@ from src.core.chat_services import (
|
||||
from src.core.services import UserService
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.delivery import delivery_service
|
||||
from src.utils.account_utils import parse_accounts_from_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatStates(StatesGroup):
|
||||
"""Состояния для работы в чате"""
|
||||
@@ -66,7 +70,7 @@ async def enter_chat(message: Message, state: FSMContext):
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🚪 Выйти из чата", callback_data="exit_chat")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
])
|
||||
|
||||
# Обычная клавиатура для чата
|
||||
@@ -118,7 +122,7 @@ async def exit_chat(message: Message, state: FSMContext):
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
])
|
||||
|
||||
# Обычная клавиатура
|
||||
@@ -478,175 +482,195 @@ async def broadcast_message_with_scheduler(
|
||||
return forwarded_ids, success_count, fail_count
|
||||
|
||||
|
||||
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
|
||||
async def _send_message_to_user(message: Message, user_telegram_id: int, retry: bool = True) -> 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
|
||||
result = await delivery_service.send_with_guard(
|
||||
user_telegram_id,
|
||||
lambda: message.copy_to(user_telegram_id),
|
||||
retry=retry,
|
||||
)
|
||||
if result.success:
|
||||
return result.telegram_message.message_id
|
||||
|
||||
logger.warning(
|
||||
"Не удалось отправить сообщение %s: %s",
|
||||
user_telegram_id,
|
||||
result.status,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||
async def _send_message_to_user_with_sender(
|
||||
message: Message,
|
||||
user_telegram_id: int,
|
||||
sender_info: str,
|
||||
retry: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение обычному пользователю с информацией об отправителе.
|
||||
Возвращает message_id при успехе или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>{sender_info}:</b>\n\n"
|
||||
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>{sender_info}:</b>\n\n"
|
||||
|
||||
async def send_message():
|
||||
if message.text:
|
||||
# Текстовое сообщение
|
||||
sent_msg = await message.bot.send_message(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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)
|
||||
return await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
|
||||
elif message.voice:
|
||||
# Голосовое сообщение
|
||||
sent_msg = await message.bot.send_voice(
|
||||
return 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
|
||||
return await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
|
||||
|
||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||
return await message.copy_to(user_telegram_id)
|
||||
|
||||
result = await delivery_service.send_with_guard(
|
||||
user_telegram_id,
|
||||
send_message,
|
||||
retry=retry,
|
||||
)
|
||||
if result.success:
|
||||
return result.telegram_message.message_id
|
||||
|
||||
logger.warning(
|
||||
"Не удалось отправить сообщение %s: %s",
|
||||
user_telegram_id,
|
||||
result.status,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||
async def _send_message_to_admin_with_sender(
|
||||
message: Message,
|
||||
admin_telegram_id: int,
|
||||
sender_info: str,
|
||||
retry: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение админу с информацией об отправителе.
|
||||
Возвращает message_id при успехе или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
||||
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
||||
|
||||
async def send_message():
|
||||
if message.text:
|
||||
# Текстовое сообщение
|
||||
sent_msg = await message.bot.send_message(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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)
|
||||
return await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
||||
elif message.voice:
|
||||
# Голосовое сообщение
|
||||
sent_msg = await message.bot.send_voice(
|
||||
return 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
|
||||
return await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
||||
|
||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||
return await message.copy_to(admin_telegram_id)
|
||||
|
||||
result = await delivery_service.send_with_guard(
|
||||
admin_telegram_id,
|
||||
send_message,
|
||||
retry=retry,
|
||||
)
|
||||
if result.success:
|
||||
return result.telegram_message.message_id
|
||||
|
||||
logger.warning(
|
||||
"Не удалось отправить сообщение админу %s: %s",
|
||||
admin_telegram_id,
|
||||
result.status,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
||||
@@ -656,7 +680,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
||||
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}")
|
||||
logger.warning(f"Не удалось переслать сообщение в канал {channel_id}: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
@@ -992,7 +1016,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
||||
await message.answer("✅ Стикер переслан в канал")
|
||||
|
||||
|
||||
@router.message(F.voice)
|
||||
@router.message(F.voice, StateFilter(ChatStates.in_chat))
|
||||
async def handle_voice_message(message: Message):
|
||||
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
|
||||
await message.answer(
|
||||
@@ -1002,7 +1026,7 @@ async def handle_voice_message(message: Message):
|
||||
return
|
||||
|
||||
|
||||
@router.message(F.audio)
|
||||
@router.message(F.audio, StateFilter(ChatStates.in_chat))
|
||||
async def handle_audio_message(message: Message):
|
||||
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
|
||||
await message.answer(
|
||||
|
||||
@@ -20,9 +20,10 @@ 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")]
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -31,7 +32,7 @@ def get_back_to_help_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура возврата к справке"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="◀️ Назад к справке", callback_data="help_main")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
||||
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -56,6 +57,7 @@ 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> - список доступных команд"
|
||||
)
|
||||
@@ -137,6 +139,61 @@ 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):
|
||||
"""Справка по чату"""
|
||||
|
||||
@@ -7,6 +7,7 @@ from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
from src.core.p2p_services import P2PMessageService
|
||||
@@ -14,6 +15,7 @@ from src.core.services import UserService
|
||||
from src.core.models import User
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.delivery import delivery_service
|
||||
|
||||
|
||||
router = Router(name='p2p_chat_router')
|
||||
@@ -30,6 +32,35 @@ 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):
|
||||
"""
|
||||
@@ -95,9 +126,13 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех зарегистрированных пользователей кроме себя
|
||||
users = await UserService.get_all_users(session)
|
||||
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.where(User.telegram_id != callback.from_user.id, User.is_registered == True)
|
||||
.order_by(User.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||
@@ -105,8 +140,8 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
# Создаём кнопки с пользователями (по 1 на строку)
|
||||
buttons = []
|
||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||
display_name = f"@{user.username}" if user.username else user.first_name
|
||||
for user in users:
|
||||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||
if user.club_card_number:
|
||||
display_name += f" (карта: {user.club_card_number})"
|
||||
|
||||
@@ -162,14 +197,18 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||||
await state.set_state(P2PChatStates.chatting)
|
||||
|
||||
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
|
||||
recipient_name = recipient.nickname or f"@{recipient.username}" or recipient.first_name or "Unknown"
|
||||
|
||||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||
|
||||
if messages:
|
||||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
||||
# Определяем имя отправителя
|
||||
is_current = msg.sender_id == sender.id
|
||||
user_for_display = sender if is_current else recipient
|
||||
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
||||
|
||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||
text += f"• {sender_name}: {msg_text}\n"
|
||||
text += "\n"
|
||||
@@ -204,7 +243,7 @@ async def show_conversations(callback: CallbackQuery):
|
||||
last_name=callback.from_user.last_name
|
||||
)
|
||||
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, sender.id, limit=10)
|
||||
|
||||
if not conversations:
|
||||
await callback.message.edit_text(
|
||||
@@ -217,7 +256,7 @@ async def show_conversations(callback: CallbackQuery):
|
||||
|
||||
buttons = []
|
||||
for peer, last_msg, unread in conversations:
|
||||
peer_name = f"@{peer.username}" if peer.username else peer.first_name
|
||||
peer_name = peer.nickname or f"@{peer.username}" or peer.first_name or "Unknown"
|
||||
|
||||
# Иконка в зависимости от непрочитанных
|
||||
icon = "🔴" if unread > 0 else "💬"
|
||||
@@ -234,7 +273,11 @@ async def show_conversations(callback: CallbackQuery):
|
||||
callback_data=f"p2p:user:{peer.id}"
|
||||
)])
|
||||
|
||||
text += f"{icon} <b>{peer_name}</b>\n"
|
||||
text += f"{icon} <b>{peer_name}</b>"
|
||||
# Показываем номер карты если есть
|
||||
if peer.club_card_number:
|
||||
text += f" (карта: {peer.club_card_number})"
|
||||
text += "\n"
|
||||
text += f" {preview}\n"
|
||||
if unread > 0:
|
||||
text += f" 📨 Непрочитанных: {unread}\n"
|
||||
@@ -268,12 +311,53 @@ async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||||
"""Вернуться в главное меню"""
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
# Имитируем команду /chat
|
||||
fake_message = callback.message
|
||||
fake_message.from_user = callback.from_user
|
||||
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
|
||||
)
|
||||
|
||||
if not user:
|
||||
await callback.message.edit_text("❌ Вы не зарегистрированы. Используйте /start")
|
||||
return
|
||||
|
||||
# Получаем количество непрочитанных сообщений
|
||||
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||||
|
||||
# Получаем последние диалоги
|
||||
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||||
|
||||
await show_chat_menu(fake_message, state)
|
||||
text = "💬 <b>Чат</b>\n\n"
|
||||
|
||||
if unread_count > 0:
|
||||
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||||
|
||||
text += "Выберите действие:"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(
|
||||
text="✉️ Написать пользователю",
|
||||
callback_data="p2p:select_user"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📋 Мои диалоги",
|
||||
callback_data="p2p:my_conversations"
|
||||
)]
|
||||
]
|
||||
|
||||
if recent:
|
||||
text += "\n\n<b>Последние диалоги:</b>\n"
|
||||
for peer, last_msg, unread in recent:
|
||||
unread_badge = f" ({unread})" if unread > 0 else ""
|
||||
text += f" • @{peer.username or peer.first_name}{unread_badge}\n"
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||||
|
||||
|
||||
# Обработчик сообщений в состоянии chatting
|
||||
@@ -301,7 +385,19 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name
|
||||
)
|
||||
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||
|
||||
# Получаем информацию о получателе для определения как подписать сообщение
|
||||
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
|
||||
|
||||
# Формируем подпись сообщения для получателя
|
||||
if sender.is_admin:
|
||||
sender_name = "АДМИН"
|
||||
else:
|
||||
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
||||
|
||||
# Добавляем карту если получатель админ
|
||||
if recipient and recipient.is_admin and sender.club_card_number:
|
||||
sender_name += f" (карта: {sender.club_card_number})"
|
||||
|
||||
# Определяем тип сообщения
|
||||
message_type = "text"
|
||||
@@ -321,49 +417,58 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
file_id = message.document.file_id
|
||||
text = message.caption
|
||||
|
||||
# Отправляем сообщение получателю
|
||||
try:
|
||||
async def send_to_recipient():
|
||||
if message_type == "text":
|
||||
sent = await message.bot.send_message(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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(
|
||||
return 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"
|
||||
)
|
||||
|
||||
# Сохраняем в БД
|
||||
await P2PMessageService.send_message(
|
||||
session,
|
||||
sender_id=sender.id,
|
||||
recipient_id=recipient_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
sender_message_id=message.message_id,
|
||||
recipient_message_id=sent.message_id
|
||||
)
|
||||
|
||||
await message.answer("✅ Сообщение доставлено")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|
||||
|
||||
return await message.copy_to(recipient_telegram_id)
|
||||
|
||||
delivery = await delivery_service.send_with_guard(
|
||||
recipient_telegram_id,
|
||||
send_to_recipient,
|
||||
)
|
||||
if not delivery.success:
|
||||
if delivery.status in {"blocked_bot", "deactivated", "not_found", "chat_not_found"}:
|
||||
await message.answer("❌ Получатель недоступен: он мог заблокировать бота")
|
||||
else:
|
||||
await message.answer("❌ Не удалось доставить сообщение. Попробуйте позже")
|
||||
return
|
||||
|
||||
# Сохраняем в БД только реально доставленное сообщение.
|
||||
await P2PMessageService.send_message(
|
||||
session,
|
||||
sender_id=sender.id,
|
||||
recipient_id=recipient_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
sender_message_id=message.message_id,
|
||||
recipient_message_id=delivery.telegram_message.message_id
|
||||
)
|
||||
|
||||
await message.answer("✅ Сообщение доставлено")
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.core.services import LotteryService
|
||||
from src.core.models import User, Winner
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.permissions import admin_only
|
||||
from src.core.delivery import delivery_service
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -276,18 +277,26 @@ async def redraw_lottery(message: Message):
|
||||
)]
|
||||
])
|
||||
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
notification_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
delivery = await delivery_service.send_with_guard(
|
||||
owner.telegram_id,
|
||||
lambda: message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
notification_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
),
|
||||
)
|
||||
|
||||
if delivery.success:
|
||||
new_winner.is_notified = True
|
||||
await session.commit()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Не удалось уведомить нового победителя %s: %s",
|
||||
owner.telegram_id,
|
||||
delivery.status,
|
||||
)
|
||||
|
||||
# Формируем отчет для админа
|
||||
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
|
||||
@@ -416,10 +425,12 @@ async def confirm_winner_callback(callback_query):
|
||||
from aiogram import Bot
|
||||
from src.core.config import BOT_TOKEN
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
|
||||
await delivery_service.send_with_guard(
|
||||
admin_id,
|
||||
lambda: bot.send_message(admin_id, admin_text, parse_mode="Markdown"),
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
|
||||
|
||||
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -11,6 +13,7 @@ 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()
|
||||
@@ -143,7 +146,20 @@ async def process_club_card(message: Message, state: FSMContext):
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||
async def process_phone(message: Message, state: FSMContext):
|
||||
"""Обработка номера телефона"""
|
||||
phone = None if message.text.strip() == "-" else message.text.strip()
|
||||
phone_input = message.text.strip()
|
||||
|
||||
# Проверяем, не отправил ли пользователь просто "-"
|
||||
if phone_input == "-":
|
||||
phone = None
|
||||
else:
|
||||
# Валидируем телефон: не должно быть пустых или некорректных значений
|
||||
if not phone_input:
|
||||
await message.answer(
|
||||
"❌ Неверный номер телефона.\n\n"
|
||||
"Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
|
||||
)
|
||||
return
|
||||
phone = phone_input
|
||||
|
||||
data = await state.get_data()
|
||||
club_card_number = data['club_card_number']
|
||||
@@ -168,12 +184,12 @@ async def process_phone(message: Message, state: FSMContext):
|
||||
"✅ Регистрация завершена!\n\n"
|
||||
f"🎭 Никнейм: {user.nickname}\n"
|
||||
f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
|
||||
f"🔑 Ваш код верификации: <b>{user.verification_code}</b>\n\n"
|
||||
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
||||
"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
await state.clear()
|
||||
|
||||
except ValueError as e:
|
||||
@@ -199,17 +215,17 @@ async def show_verification_code(message: Message):
|
||||
|
||||
text = (
|
||||
"🔑 Ваш код верификации:\n\n"
|
||||
f"**{user.verification_code}**\n\n"
|
||||
f"<code>{user.verification_code}</code>\n\n"
|
||||
"Этот код используется для подтверждения выигрыша.\n"
|
||||
"Сообщите его администратору при получении приза."
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -221,15 +237,69 @@ async def show_user_accounts(message: Message):
|
||||
|
||||
if not accounts:
|
||||
await message.answer(
|
||||
"У вас пока нет привязанных счетов.\n\n"
|
||||
"Счета добавляются администратором."
|
||||
"У вас пока нет привязанных логинов.\n\n"
|
||||
"Логины добавляются администратором."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
|
||||
text = f"📱 <b>Ваши логины</b> (Клубная карта: {user.club_card_number})\n\n"
|
||||
|
||||
for i, account in enumerate(accounts, 1):
|
||||
status = "✅" if account.is_active else "❌"
|
||||
text += f"{i}. {status} {account.account_number}\n"
|
||||
# Получаем participations для этого account с загруженными данными о lottery
|
||||
participations = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.account_id == account.id)
|
||||
.options(selectinload(Participation.lottery))
|
||||
)
|
||||
participations = participations.scalars().all()
|
||||
|
||||
# Определяем статус логина
|
||||
active_participations = [p for p in participations if not p.lottery.is_completed]
|
||||
closed_participations = [p for p in participations if p.lottery.is_completed]
|
||||
|
||||
# Основная информация о логине
|
||||
status_icon = "✅" if account.is_active and active_participations else "⏸️"
|
||||
text += f"{i}. {status_icon} <b>{account.account_number}</b>\n"
|
||||
|
||||
if active_participations:
|
||||
text += " 🎲 <b>Активные розыгрыши:</b>\n"
|
||||
for p in active_participations[:5]: # Показываем не более 5
|
||||
status = "🟢"
|
||||
text += f" {status} {p.lottery.title}\n"
|
||||
if len(active_participations) > 5:
|
||||
text += f" ... и еще {len(active_participations) - 5}\n"
|
||||
|
||||
if closed_participations:
|
||||
text += " 🏁 <b>Завершенные розыгрыши:</b>\n"
|
||||
for p in closed_participations[:3]: # Показываем не более 3
|
||||
# Проверяем, выиграл ли в этом розыгрыше
|
||||
winner_result = await session.execute(
|
||||
select(Winner)
|
||||
.where(
|
||||
(Winner.lottery_id == p.lottery_id) &
|
||||
(Winner.account_number == account.account_number)
|
||||
)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if winner:
|
||||
text += f" 🏆 {p.lottery.title} - <b>ВЫИГРАЛ!</b> ({winner.place} место)\n"
|
||||
else:
|
||||
text += f" ✗ {p.lottery.title}\n"
|
||||
|
||||
if len(closed_participations) > 3:
|
||||
text += f" ... и еще {len(closed_participations) - 3} закрытых\n"
|
||||
|
||||
if not participations:
|
||||
text += " ℹ️ В розыгрышах не участвовал\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
await message.answer(text)
|
||||
# Добавляем примечание о неактивных логинах
|
||||
if any(not acc.is_active for acc in accounts):
|
||||
text += "⏸️ - Логин не участвует в новых розыгрышах\n"
|
||||
text += "✅ - Логин активен и может участвовать\n\n"
|
||||
|
||||
text += "💡 <b>Заметка:</b> Логины, участвовавшие в закрытых розыгрышах, не добавляются в новые розыгрыши."
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
@@ -5,15 +5,19 @@ from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
import logging
|
||||
import time
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.activity_service import ActivityService
|
||||
from src.core.config import ACTIVITY_UPDATE_INTERVAL_SECONDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActivityMiddleware(BaseMiddleware):
|
||||
"""Middleware для обновления last_activity при каждом взаимодействии"""
|
||||
|
||||
_last_updates: Dict[int, float] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -34,19 +38,36 @@ class ActivityMiddleware(BaseMiddleware):
|
||||
elif event.callback_query and event.callback_query.from_user:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
# Обновляем активность если есть telegram_id
|
||||
if telegram_id:
|
||||
# Обновляем активность не чаще заданного интервала.
|
||||
if telegram_id and self._should_update(telegram_id):
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Обновляем активность
|
||||
await ActivityService.update_user_activity(session, telegram_id)
|
||||
|
||||
# Проверяем, не был ли пользователь заблокирован за неактивность
|
||||
# Если был - реактивируем
|
||||
await ActivityService.reactivate_user(session, telegram_id)
|
||||
|
||||
reactivated = await ActivityService.update_activity_and_reactivate(session, telegram_id)
|
||||
if reactivated is None:
|
||||
self._last_updates.pop(telegram_id, None)
|
||||
return await handler(event, data)
|
||||
if reactivated:
|
||||
logger.info(f"Пользователь {telegram_id} реактивирован")
|
||||
except Exception as e:
|
||||
self._last_updates.pop(telegram_id, None)
|
||||
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
|
||||
|
||||
# Вызываем следующий обработчик
|
||||
return await handler(event, data)
|
||||
|
||||
@classmethod
|
||||
def _should_update(cls, telegram_id: int) -> bool:
|
||||
now = time.monotonic()
|
||||
last_update = cls._last_updates.get(telegram_id)
|
||||
if last_update and now - last_update < ACTIVITY_UPDATE_INTERVAL_SECONDS:
|
||||
return False
|
||||
|
||||
cls._last_updates[telegram_id] = now
|
||||
if len(cls._last_updates) > 10000:
|
||||
cutoff = now - ACTIVITY_UPDATE_INTERVAL_SECONDS
|
||||
cls._last_updates = {
|
||||
user_id: updated_at
|
||||
for user_id, updated_at in cls._last_updates.items()
|
||||
if updated_at >= cutoff
|
||||
}
|
||||
return True
|
||||
|
||||
@@ -17,8 +17,8 @@ def get_main_reply_keyboard(is_admin: bool = False, is_registered: bool = False)
|
||||
|
||||
# Первая строка - основные команды
|
||||
row1 = [
|
||||
KeyboardButton(text="🎰 Розыгрыши"),
|
||||
KeyboardButton(text="💬 Чат")
|
||||
KeyboardButton(text="💬 Чат"),
|
||||
KeyboardButton(text="❓ Справка")
|
||||
]
|
||||
keyboard.append(row1)
|
||||
|
||||
@@ -29,13 +29,13 @@ def get_main_reply_keyboard(is_admin: bool = False, is_registered: bool = False)
|
||||
|
||||
if is_registered or is_admin:
|
||||
row2.append(KeyboardButton(text="🔑 Мой код"))
|
||||
row2.append(KeyboardButton(text="💳 Мои счета"))
|
||||
row2.append(KeyboardButton(text="📱 Мои логины"))
|
||||
|
||||
if row2:
|
||||
keyboard.append(row2)
|
||||
|
||||
# Третья строка - справка
|
||||
row3 = [KeyboardButton(text="❓ Справка")]
|
||||
# Третья строка - главная
|
||||
row3 = [KeyboardButton(text="🏠 Главная")]
|
||||
|
||||
# Админские команды
|
||||
if is_admin:
|
||||
@@ -59,7 +59,7 @@ def get_chat_reply_keyboard() -> ReplyKeyboardMarkup:
|
||||
"""
|
||||
keyboard = [
|
||||
[KeyboardButton(text="🚪 Выйти из чата")],
|
||||
[KeyboardButton(text="🏠 Главное меню")]
|
||||
[KeyboardButton(text="🏠 Главная")]
|
||||
]
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..core.models import Winner, User
|
||||
from ..core.services import LotteryService
|
||||
from ..core.registration_services import AccountService, WinnerNotificationService
|
||||
from ..core.config import ADMIN_IDS
|
||||
from ..core.delivery import delivery_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -79,19 +80,28 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
|
||||
)]
|
||||
])
|
||||
|
||||
# Отправляем уведомление с кнопкой
|
||||
await bot.send_message(
|
||||
owner.telegram_id,
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
delivery = await delivery_service.send_with_guard(
|
||||
owner.telegram_id,
|
||||
lambda: bot.send_message(
|
||||
owner.telegram_id,
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
),
|
||||
)
|
||||
|
||||
# Отмечаем, что уведомление отправлено
|
||||
winner.is_notified = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
|
||||
|
||||
if delivery.success:
|
||||
# Отмечаем, что уведомление отправлено
|
||||
winner.is_notified = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Уведомление победителю %s не отправлено: %s",
|
||||
owner.telegram_id,
|
||||
delivery.status,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
|
||||
|
||||
@@ -118,16 +128,26 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
|
||||
)]
|
||||
])
|
||||
|
||||
await bot.send_message(
|
||||
delivery = await delivery_service.send_with_guard(
|
||||
user.telegram_id,
|
||||
message,
|
||||
reply_markup=keyboard
|
||||
lambda: bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
reply_markup=keyboard
|
||||
),
|
||||
)
|
||||
|
||||
winner.is_notified = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
|
||||
|
||||
if delivery.success:
|
||||
winner.is_notified = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Уведомление победителю %s не отправлено: %s",
|
||||
user.telegram_id,
|
||||
delivery.status,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
|
||||
|
||||
|
||||
@@ -151,18 +151,19 @@ class AsyncTaskManager:
|
||||
task = await asyncio.wait_for(self.task_queue.get(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
# Получаем семафоры
|
||||
async with self.worker_semaphore:
|
||||
user_semaphore = self._get_user_semaphore(task.user_id)
|
||||
if user_semaphore is not None:
|
||||
async with user_semaphore:
|
||||
|
||||
try:
|
||||
# Получаем семафоры
|
||||
async with self.worker_semaphore:
|
||||
user_semaphore = self._get_user_semaphore(task.user_id)
|
||||
if user_semaphore is not None:
|
||||
async with user_semaphore:
|
||||
await self._execute_task(worker_name, task)
|
||||
else:
|
||||
await self._execute_task(worker_name, task)
|
||||
else:
|
||||
await self._execute_task(worker_name, task)
|
||||
|
||||
# Отмечаем задачу как выполненную
|
||||
self.task_queue.task_done()
|
||||
finally:
|
||||
# Отмечаем задачу как обработанную даже при ошибке выполнения.
|
||||
self.task_queue.task_done()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"Воркер {worker_name} отменён")
|
||||
@@ -265,4 +266,4 @@ class AsyncTaskManager:
|
||||
task_manager = AsyncTaskManager(
|
||||
max_workers=15, # Максимум воркеров
|
||||
max_user_concurrent=5 # Максимум задач на пользователя
|
||||
)
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ async def test_comprehensive_features():
|
||||
for i in range(5):
|
||||
# Генерируем уникальные данные
|
||||
unique_id = random.randint(10000000, 99999999)
|
||||
account = f"{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}"
|
||||
account = "-".join(f"{random.randint(10,99)}" for _ in range(7))
|
||||
|
||||
user = await UserService.get_or_create_user(
|
||||
session,
|
||||
@@ -52,7 +52,7 @@ async def test_comprehensive_features():
|
||||
title="Тест массовых операций",
|
||||
description="Розыгрыш для тестирования массовых операций",
|
||||
prizes=["Приз 1", "Приз 2"],
|
||||
creator_id=1
|
||||
creator_id=test_users[0].id
|
||||
)
|
||||
print(f"✅ Розыгрыш создан: {lottery.title}")
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ async def test_basic_functionality():
|
||||
# 1. Тест создания пользователя с номером счёта
|
||||
print("\n1. 👤 Создание пользователя с номером счёта:")
|
||||
|
||||
test_account = "12-34-56-78-90-12-34-56"
|
||||
test_account = "12-34-56-78-90-12-34"
|
||||
user = await UserService.get_or_create_user(
|
||||
session,
|
||||
telegram_id=999999999,
|
||||
@@ -49,7 +49,7 @@ async def test_basic_functionality():
|
||||
print("\n2. 📋 Тестирование валидации номеров:")
|
||||
|
||||
test_cases = [
|
||||
("12-34-56-78-90-12-34-56", True),
|
||||
("12-34-56-78-90-12-34", True),
|
||||
("invalid-number", False),
|
||||
("12345678901234567890", False)
|
||||
]
|
||||
@@ -84,7 +84,7 @@ async def test_basic_functionality():
|
||||
title="Тест отображения",
|
||||
description="Тестовый розыгрыш",
|
||||
prizes=["Приз 1"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
display_types = ['username', 'chat_id', 'account_number']
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_features():
|
||||
async with async_session_maker() as session:
|
||||
# Генерируем уникальные тестовые данные
|
||||
unique_id = random.randint(1000000, 9999999)
|
||||
test_account = f"{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}"
|
||||
test_account = "-".join(f"{random.randint(10, 99)}" for _ in range(7))
|
||||
|
||||
print(f"🆔 Используем уникальный ID: {unique_id}")
|
||||
print(f"🔢 Используем номер счёта: {test_account}")
|
||||
@@ -44,9 +44,7 @@ async def test_features():
|
||||
|
||||
if success:
|
||||
print(f"✅ Номер счёта {test_account} установлен")
|
||||
# Обновляем данные пользователя
|
||||
await session.refresh(user)
|
||||
print(f"✅ Подтверждено: {user.account_number}")
|
||||
print(f"✅ Подтверждено: {test_account}")
|
||||
else:
|
||||
print(f"❌ Не удалось установить номер счёта")
|
||||
return
|
||||
@@ -72,7 +70,7 @@ async def test_features():
|
||||
title="Тест отображения",
|
||||
description="Тестовый розыгрыш",
|
||||
prizes=["Приз 1"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
# Тестируем все типы отображения
|
||||
|
||||
130
tests/test_delivery_guard.py
Normal file
130
tests/test_delivery_guard.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import src.core.models # noqa: F401 - register SQLAlchemy models before init_db
|
||||
from src.core.activity_service import ActivityService
|
||||
from src.core.database import async_session_maker, init_db
|
||||
from src.core.delivery import DeliveryService
|
||||
from src.core.models import BlockedUser
|
||||
|
||||
|
||||
class FakeTelegramMessage:
|
||||
message_id = 123
|
||||
|
||||
|
||||
async def _cleanup_blocked_user(telegram_id: int):
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(
|
||||
delete(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _get_blocked_user(telegram_id: int):
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_guard_skips_known_blocked_user():
|
||||
await init_db()
|
||||
telegram_id = 880000001
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
session.add(
|
||||
BlockedUser(
|
||||
telegram_id=telegram_id,
|
||||
error_type="blocked_bot",
|
||||
error_message="Forbidden: bot was blocked by the user",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
called = False
|
||||
|
||||
async def send_call():
|
||||
nonlocal called
|
||||
called = True
|
||||
return FakeTelegramMessage()
|
||||
|
||||
result = await DeliveryService().send_with_guard(telegram_id, send_call)
|
||||
|
||||
assert result.success is False
|
||||
assert result.skipped is True
|
||||
assert result.status == "blocked_bot"
|
||||
assert called is False
|
||||
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_guard_marks_user_blocked_on_forbidden():
|
||||
await init_db()
|
||||
telegram_id = 880000002
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
|
||||
async def send_call():
|
||||
raise TelegramForbiddenError(
|
||||
method=None,
|
||||
message="Forbidden: bot was blocked by the user",
|
||||
)
|
||||
|
||||
result = await DeliveryService().send_with_guard(telegram_id, send_call)
|
||||
blocked_user = await _get_blocked_user(telegram_id)
|
||||
|
||||
assert result.success is False
|
||||
assert result.status == "blocked_bot"
|
||||
assert blocked_user is not None
|
||||
assert blocked_user.is_active is True
|
||||
assert blocked_user.error_type == "blocked_bot"
|
||||
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
|
||||
|
||||
def test_delivery_guard_does_not_classify_parse_errors_as_blocked():
|
||||
service = DeliveryService()
|
||||
|
||||
assert service.classify_bad_request(Exception("Bad Request: can't parse entities")) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_reactivates_delivery_after_user_interaction():
|
||||
await init_db()
|
||||
telegram_id = 880000003
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
session.add(
|
||||
BlockedUser(
|
||||
telegram_id=telegram_id,
|
||||
error_type="blocked_bot",
|
||||
error_message="Forbidden: bot was blocked by the user",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
reactivated = await ActivityService.update_activity_and_reactivate(
|
||||
session,
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
blocked_user = await _get_blocked_user(telegram_id)
|
||||
|
||||
assert reactivated is True
|
||||
assert blocked_user is not None
|
||||
assert blocked_user.is_active is False
|
||||
|
||||
await _cleanup_blocked_user(telegram_id)
|
||||
@@ -30,14 +30,14 @@ async def test_account_functionality():
|
||||
'username': 'client1',
|
||||
'first_name': 'Клиент',
|
||||
'last_name': 'Первый',
|
||||
'account': '12-34-56-78-90-12-34-56'
|
||||
'account': '12-34-56-78-90-12-34'
|
||||
},
|
||||
{
|
||||
'telegram_id': 777000002,
|
||||
'username': 'client2',
|
||||
'first_name': 'Клиент',
|
||||
'last_name': 'Второй',
|
||||
'account': '11-22-33-44-55-66-77-88'
|
||||
'account': '11-22-33-44-55-66-77'
|
||||
},
|
||||
{
|
||||
'telegram_id': 777000003,
|
||||
@@ -78,9 +78,9 @@ async def test_account_functionality():
|
||||
print("\n📋 Тестирование валидации номеров счетов:")
|
||||
|
||||
test_numbers = [
|
||||
("12-34-56-78-90-12-34-56", True), # Корректный
|
||||
("12345678901234567890123456", False), # Без дефисов
|
||||
("12-34-56-78-90-12-34", False), # Короткий
|
||||
("12-34-56-78-90-12-34", True), # Корректный
|
||||
("12345678901234", False), # Без дефисов
|
||||
("12-34-56-78-90-12", False), # Короткий
|
||||
("12-34-56-78-90-12-34-56-78", False), # Длинный
|
||||
("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы
|
||||
("", False) # Пустой
|
||||
@@ -93,7 +93,7 @@ async def test_account_functionality():
|
||||
|
||||
# Тест маскирования
|
||||
print("\n🎭 Тестирование маскирования номеров:")
|
||||
test_account = "12-34-56-78-90-12-34-56"
|
||||
test_account = "12-34-56-78-90-12-34"
|
||||
for digits in [4, 6, 8]:
|
||||
masked = mask_account_number(test_account, show_last_digits=digits)
|
||||
print(f"Показать последние {digits} цифр: {masked}")
|
||||
@@ -103,7 +103,7 @@ async def test_account_functionality():
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Полный номер
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56")
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
|
||||
if user:
|
||||
print(f"✅ Найден пользователь по полному номеру: {user.first_name}")
|
||||
|
||||
@@ -123,18 +123,21 @@ async def test_display_types():
|
||||
print("=" * 50)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя со счётом
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
|
||||
if not user:
|
||||
print("❌ Не найден пользователь со счетом для теста отображения")
|
||||
return
|
||||
|
||||
# Создаём розыгрыш
|
||||
lottery = await LotteryService.create_lottery(
|
||||
session,
|
||||
title="Тест отображения победителей",
|
||||
description="Розыгрыш для тестирования различных типов отображения",
|
||||
prizes=["Первый приз", "Второй приз"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
# Получаем пользователя со счётом
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56")
|
||||
|
||||
|
||||
if user and lottery:
|
||||
print(f"👤 Тестируем отображение для пользователя: {user.first_name}")
|
||||
print(f"💳 Номер счёта: {user.account_number}")
|
||||
|
||||
Reference in New Issue
Block a user