26 Commits

Author SHA1 Message Date
Андрей Цой
fb1830f77e Protect deliveries to blocked Telegram users 2026-07-02 17:49:05 +09:00
Андрей Цой
b0e42688b6 Shorten performance index migration revision 2026-07-02 06:17:22 +09:00
Андрей Цой
c7c60b70c8 Use raw SQL for performance index migration 2026-07-01 21:24:17 +09:00
Андрей Цой
26c65b7caa Harden bot against runtime stalls 2026-07-01 20:35:50 +09:00
Андрей Цой
6fbe4c4f43 Use external database in compose by default 2026-07-01 18:30:39 +09:00
Андрей Цой
3906398136 Modernize bot for Python 3.12 2026-07-01 18:24:16 +09:00
733298bf06 Merge pull request 'Fix UserService method call in P2P chat handler' (#16) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2026-03-07 02:35:14 +00:00
dbba2c4b83 Merge pull request 'Clean up P2P message format - remove emoji prefixes and simplify sender display' (#15) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-03-07 02:28:57 +00:00
fd8fc35f03 Merge pull request 'Use nickname instead of username in P2P chat display' (#14) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-03-07 02:22:38 +00:00
df3d439e62 Merge pull request 'Fix p2p_chat frozen instance error and improve sender info display' (#13) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-03-07 02:15:18 +00:00
7b50be5ae1 Merge pull request 'Fix undefined variable in p2p_chat.py show_conversations handler' (#12) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #12
2026-03-07 01:53:33 +00:00
c5a90a5153 Merge pull request 'Add custom emoji mapping system for premium emoji support' (#11) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2026-03-07 01:47:02 +00:00
62ca809f11 Merge pull request 'Fix HTML parse_mode in registration handlers to support premium emojis' (#10) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2026-03-07 00:47:52 +00:00
Lottery Bot Admin
21f348471e Add Premium Emoji Support for Premium Bot Accounts
All checks were successful
continuous-integration/drone/push Build is passing
- Create src/core/premium_emoji.py module for premium emoji handling
- Create src/core/telegram_config.py for global parse_mode configuration
- Update bot_controller.py to use HTML parse_mode for better emoji support
- Add PREMIUM_EMOJI_SUPPORT.md documentation with usage examples
- HTML parse_mode now default for all messages to support premium emojis
- Aiogram 3.16.0+ supports premium emojis natively when using correct parse_mode

Benefits:
- Premium bot accounts can now display special premium emojis
- Better emoji rendering across all message types
- Centralized configuration for parse modes
- Backwards compatible with regular emoji
2026-03-07 00:26:20 +00:00
Lottery Bot Admin
4daec268e6 Update production configuration
All checks were successful
continuous-integration/drone/push Build is passing
- Update BOT_TOKEN for production environment
- Configure external PostgreSQL host (192.168.0.102)
- Update database connection details (new_lottery_KR)
- Adjust docker-compose configuration for production setup
- Set LOG_LEVEL to DEBUG for better diagnostics
2026-03-07 00:06:01 +00:00
5c01486bd8 Merge pull request 'v2_functions' (#9) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2026-03-06 23:57:00 +00:00
7d5ad3d668 Merge pull request 'Добавить раздел 'Мои логины' в справку' (#8) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #8
2026-03-06 23:32:18 +00:00
06ddd1e5fa Merge pull request 'Обновление UI: убрать розыгрыши, переименовать счета, добавить кнопку главная' (#7) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2026-03-06 23:12:51 +00:00
815cc544d5 Merge pull request 'feat: Allow assigned admins to access admin panel via command and buttons' (#6) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #6
2026-02-18 04:29:01 +00:00
2db39b0652 Merge pull request 'feat: Add admin management system with super admin controls' (#5) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #5
2026-02-18 04:21:26 +00:00
4160d69fa7 восстановление работы чата,
All checks were successful
continuous-integration/drone/push Build is passing
рефактор проведения розыгрыша
2026-02-18 11:31:38 +09:00
6b2e915452 fix: Fix chat message broadcasting to all users
All checks were successful
continuous-integration/drone/push Build is passing
- Fixed get_all_active_users() to broadcast to ALL users regardless of registration status
- Merged duplicate text message handlers (check_exit_keywords and handle_text_message)
- Added detailed logging for chat message broadcasting
- Now users can receive messages in chat without full registration

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

View File

@@ -9,6 +9,15 @@ BOT_TOKEN=your_bot_token_here
# Для PostgreSQL (рекомендуется для продакшена): # Для PostgreSQL (рекомендуется для продакшена):
DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot 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 (через запятую) # ID администраторов Telegram (через запятую)
@@ -19,6 +28,12 @@ ADMIN_IDS=123456789,987654321
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL # Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
SQL_ECHO=false
# Обновлять last_activity не чаще указанного интервала на пользователя
ACTIVITY_UPDATE_INTERVAL_SECONDS=300
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) === # === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
# Максимальное количество участников в одном розыгрыше # Максимальное количество участников в одном розыгрыше
# MAX_PARTICIPANTS_PER_LOTTERY=10000 # MAX_PARTICIPANTS_PER_LOTTERY=10000
@@ -27,4 +42,4 @@ LOG_LEVEL=INFO
# MAX_ACTIVE_LOTTERIES=10 # MAX_ACTIVE_LOTTERIES=10
# Таймаут для операций с базой данных (секунды) # Таймаут для операций с базой данных (секунды)
# DATABASE_TIMEOUT=30 # DATABASE_TIMEOUT=30

View File

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

65
CHAT_FIX_REPORT.md Normal file
View File

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

View File

@@ -1,7 +1,10 @@
# Makefile для телеграм-бота розыгрышей # Makefile для телеграм-бота розыгрышей
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1) # Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null) 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 .PHONY: help install setup setup-postgres init-db run test clean
@@ -36,7 +39,7 @@ help:
# Установка зависимостей # Установка зависимостей
install: install:
@echo "📦 Установка зависимостей..." @echo "📦 Установка зависимостей..."
python3 -m venv .venv $(PYTHON) -m venv .venv
. .venv/bin/activate && pip install -r requirements.txt . .venv/bin/activate && pip install -r requirements.txt
# Настройка PostgreSQL базы данных # Настройка PostgreSQL базы данных
@@ -217,7 +220,7 @@ docker-setup:
# Проверка Docker и Docker Compose # Проверка Docker и Docker Compose
docker-check: docker-check:
@echo "<EFBFBD> Проверка Docker окружения..." @echo "🔍 Проверка Docker окружения..."
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; } @command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
@echo "✅ Docker: $$(docker --version)" @echo "✅ Docker: $$(docker --version)"
@if [ -z "$(DOCKER_COMPOSE)" ]; then \ @if [ -z "$(DOCKER_COMPOSE)" ]; then \
@@ -411,4 +414,4 @@ docker-external-db-help:
@echo " 3. make docker-deploy" @echo " 3. make docker-deploy"
@echo "" @echo ""
@echo "Проверить подключение:" @echo "Проверить подключение:"
@echo " make docker-test-db" @echo " make docker-test-db"

View File

@@ -7,16 +7,18 @@ services:
image: postgres:15-alpine image: postgres:15-alpine
container_name: lottery_postgres container_name: lottery_postgres
restart: unless-stopped restart: unless-stopped
profiles:
- local-db
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot} POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-lottery_user} POSTGRES_USER: ${POSTGRES_USER:-lottery}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lottery_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
- lottery_network - lottery_network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -45,18 +47,23 @@ services:
container_name: lottery_bot container_name: lottery_bot
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env.prod - .env
environment: environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - 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: volumes:
- ./logs:/app/logs - ./logs:/app/logs
- bot_data:/app/data - bot_data:/app/data
networks: networks:
- lottery_network - lottery_network
depends_on: depends_on:
postgres:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:

View File

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

37
main.py
View File

@@ -10,11 +10,12 @@ from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from src.filters.case_insensitive import CaseInsensitiveCommand 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.database import async_session_maker
from src.core.scheduler import bot_scheduler from src.core.scheduler import bot_scheduler
from src.container import container from src.container import container
@@ -31,17 +32,33 @@ from src.handlers.message_management import message_admin_router
from src.handlers.p2p_chat import router as p2p_chat_router from src.handlers.p2p_chat import router as p2p_chat_router
from src.handlers.help_handlers import router as help_router from src.handlers.help_handlers import router as help_router
from src.handlers.admin_emoji_handlers import router as admin_emoji_router from src.handlers.admin_emoji_handlers import router as admin_emoji_router
from src.utils.task_manager import task_manager
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Создание бота и диспетчера # Создание бота и диспетчера
bot = Bot(token=BOT_TOKEN) 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) dp = Dispatcher(storage=storage)
router = Router() router = Router()
@@ -50,9 +67,9 @@ router = Router()
@dp.callback_query.middleware() @dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data): async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов""" """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) 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 return result
@@ -179,7 +196,7 @@ async def btn_my_code(message: Message):
await show_verification_code(message) await show_verification_code(message)
@router.message(F.text == "<EFBFBD> Мои логины") @router.message(F.text == "📱 Мои логины")
async def btn_my_accounts(message: Message): async def btn_my_accounts(message: Message):
"""Обработчик кнопки 'Мои логины'""" """Обработчик кнопки 'Мои логины'"""
from src.handlers.registration_handlers import show_user_accounts from src.handlers.registration_handlers import show_user_accounts
@@ -308,6 +325,9 @@ async def main():
# Запускаем планировщик задач # Запускаем планировщик задач
bot_scheduler.start() bot_scheduler.start()
logger.info("Планировщик задач запущен") logger.info("Планировщик задач запущен")
await task_manager.start()
logger.info("Менеджер фоновых задач запущен")
# Запускаем polling # Запускаем polling
try: try:
@@ -318,6 +338,9 @@ async def main():
finally: finally:
# Останавливаем планировщик # Останавливаем планировщик
bot_scheduler.shutdown() bot_scheduler.shutdown()
await task_manager.stop()
if hasattr(storage, "close"):
await storage.close()
await bot.session.close() await bot.session.close()
@@ -327,4 +350,4 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Бот остановлен пользователем") logger.info("Бот остановлен пользователем")
except Exception as e: except Exception as e:
logger.error(f"Критическая ошибка: {e}") logger.error(f"Критическая ошибка: {e}")

View File

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

View File

@@ -17,72 +17,10 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # Duplicate autogenerated migration. The real changes are applied by
op.create_table('blocked_users', # 71376bb89294, 1f1631301809 and b4c435a7dc5f.
sa.Column('id', sa.Integer(), nullable=False), pass
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 ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### pass
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 ###

View File

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

View 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
View 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"]

View File

@@ -1,12 +1,15 @@
# Updated for Python 3.12 compatibility # Runtime target: Python 3.12+
aiogram==3.16.0 aiogram==3.29.0
aiohttp>=3.11.0 aiohttp==3.14.1
sqlalchemy==2.0.36 SQLAlchemy==2.0.51
alembic==1.14.0 alembic==1.18.5
python-dotenv==1.0.1 python-dotenv==1.2.2
asyncpg==0.30.0 asyncpg==0.31.0
aiosqlite==0.20.0 aiosqlite==0.22.1
redis==5.2.1 redis==8.0.1
aioredis==2.0.1 APScheduler==3.11.3
apscheduler==3.10.4 openpyxl==3.1.5
openpyxl==3.1.2
# Development / diagnostics
pytest==9.1.1
pytest-asyncio==1.4.0

View File

@@ -3,12 +3,10 @@
Скрипт для очистки и инициализации БД с тестовыми данными Скрипт для очистки и инициализации БД с тестовыми данными
""" """
import asyncio import asyncio
import random from src.core.database import async_session_maker
from database import async_session_maker, Base, engine from src.core.models import User, Lottery, Participation, Winner, Account
from models import User, Lottery, Participation, Winner from src.core.services import LotteryService, UserService, ParticipationService
from services import LotteryService, UserService, ParticipationService from sqlalchemy import delete, select, func
from datetime import datetime, timedelta
from sqlalchemy import delete, update, select, func
async def clear_database(): async def clear_database():
"""Очистка всех таблиц""" """Очистка всех таблиц"""
@@ -18,6 +16,7 @@ async def clear_database():
# Удаляем все данные в правильном порядке (учитывая внешние ключи) # Удаляем все данные в правильном порядке (учитывая внешние ключи)
await session.execute(delete(Winner)) await session.execute(delete(Winner))
await session.execute(delete(Participation)) await session.execute(delete(Participation))
await session.execute(delete(Account))
await session.execute(delete(Lottery)) await session.execute(delete(Lottery))
await session.execute(delete(User)) await session.execute(delete(User))
await session.commit() await session.commit()
@@ -26,8 +25,10 @@ async def clear_database():
def generate_account_numbers(user_id: int, count: int = 10) -> list: def generate_account_numbers(user_id: int, count: int = 10) -> list:
"""Генерация номеров счетов для пользователя""" """Генерация номеров счетов для пользователя"""
base = user_id * 1000 return [
return [f"{base + i:06d}" for i in range(1, count + 1)] "-".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(): async def create_test_users():
"""Создание тестовых пользователей""" """Создание тестовых пользователей"""
@@ -62,12 +63,8 @@ async def create_test_users():
# Генерируем и присваиваем номера счетов # Генерируем и присваиваем номера счетов
account_numbers = generate_account_numbers(i, 10) account_numbers = generate_account_numbers(i, 10)
# Обновляем счета for account_number in account_numbers:
await session.execute( await UserService.set_account_number(session, user.telegram_id, account_number)
update(User)
.where(User.id == user.id)
.values(account_number=",".join(account_numbers))
)
await session.commit() await session.commit()
created_users.append((user, account_numbers)) created_users.append((user, account_numbers))
@@ -194,4 +191,4 @@ async def main():
print("\n🎉 Инициализация завершена!") print("\n🎉 Инициализация завершена!")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

@@ -2,8 +2,8 @@
Примеры использования API сервисов для тестирования Примеры использования API сервисов для тестирования
""" """
import asyncio import asyncio
from database import async_session_maker, init_db from src.core.database import async_session_maker, init_db
from services import UserService, LotteryService, ParticipationService from src.core.services import UserService, LotteryService, ParticipationService
async def example_usage(): async def example_usage():
@@ -61,7 +61,7 @@ async def example_usage():
# Добавляем участников # Добавляем участников
for user in users: 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: if success:
print(f"{user.first_name} присоединился к розыгрышу") print(f"{user.first_name} присоединился к розыгрышу")
@@ -93,6 +93,7 @@ async def example_usage():
# Проводим розыгрыш # Проводим розыгрыш
results = await LotteryService.conduct_draw(session, lottery.id) results = await LotteryService.conduct_draw(session, lottery.id)
await session.commit()
print(f"\n🏆 Результаты розыгрыша:") print(f"\n🏆 Результаты розыгрыша:")
print(f"🎯 Розыгрыш: {lottery.title}") print(f"🎯 Розыгрыш: {lottery.title}")
@@ -134,7 +135,7 @@ async def test_edge_cases():
user = await UserService.get_user_by_telegram_id(session, 100000001) user = await UserService.get_user_by_telegram_id(session, 100000001)
lottery = (await LotteryService.get_active_lotteries(session))[0] 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 '⚠️ Разрешено'}") print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
# Попытка установить ручного победителя для несуществующего пользователя # Попытка установить ручного победителя для несуществующего пользователя

View File

@@ -45,7 +45,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
class MessageFormatterImpl(IMessageFormatter): class MessageFormatterImpl(IMessageFormatter):
"""Реализация форматирования сообщений""" """Реализация форматирования сообщений"""
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False): def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
"""Получить клавиатуру для конкретного розыгрыша""" """Получить клавиатуру для конкретного розыгрыша"""
buttons = [ buttons = [
@@ -72,12 +72,8 @@ class MessageFormatterImpl(IMessageFormatter):
text = text[:47] + "..." text = text[:47] + "..."
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")]) 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) return InlineKeyboardMarkup(inline_keyboard=buttons)
class MessageFormatterImpl(IMessageFormatter):
"""Реализация форматирования сообщений"""
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str: 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('completed_lotteries', 0)}\n"
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n" text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
return text return text

View File

@@ -5,6 +5,7 @@ import logging
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
from src.interfaces.base import ILotteryRepository, IParticipationRepository from src.interfaces.base import ILotteryRepository, IParticipationRepository
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.telegram_config import get_parse_mode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -88,7 +89,7 @@ class BotController(IBotController):
await callback.answer("❌ Нет активных розыгрышей", show_alert=True) await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
return return
text = "🎲 **Активные розыгрыши:**\n\n" text = "🎲 <b>Активные розыгрыши:</b>\n\n"
for lottery in lotteries: for lottery in lotteries:
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id) participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
@@ -112,7 +113,7 @@ class BotController(IBotController):
await callback.message.edit_text( await callback.message.edit_text(
text, text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode=get_parse_mode("inline_keyboard")
) )
except Exception as e: except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback # Если сообщение не изменилось - просто отвечаем на callback
@@ -124,5 +125,5 @@ class BotController(IBotController):
await callback.message.answer( await callback.message.answer(
text, text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="Markdown" parse_mode=get_parse_mode("inline_keyboard")
) )

View File

@@ -5,7 +5,7 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy import select, and_, update from sqlalchemy import select, and_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import List from typing import List, Optional
import logging import logging
from .models import User, BlockedUser from .models import User, BlockedUser
@@ -40,6 +40,34 @@ class ActivityService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}") logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
await session.rollback() 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 @staticmethod
async def get_inactive_users( async def get_inactive_users(
@@ -84,35 +112,45 @@ class ActivityService:
Количество помеченных пользователей Количество помеченных пользователей
""" """
try: try:
inactive_users = await ActivityService.get_inactive_users(session, days) if days is None:
marked_count = 0 days = ActivityService.INACTIVITY_PERIOD_DAYS
for user in inactive_users: cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
# Проверяем, не помечен ли уже already_blocked = (
stmt = select(BlockedUser).where( select(BlockedUser.id)
.where(
and_( and_(
BlockedUser.telegram_id == user.telegram_id, BlockedUser.telegram_id == User.telegram_id,
BlockedUser.error_type == 'inactive', BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True BlockedUser.is_active == True,
) )
) )
result = await session.execute(stmt) .exists()
existing = result.scalar_one_or_none() )
result = await session.execute(
if not existing: select(User).where(
# Создаем новую запись and_(
blocked = BlockedUser( User.last_activity < cutoff_date,
telegram_id=user.telegram_id, User.is_registered == True,
error_type='inactive', ~already_blocked,
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
) )
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() await session.commit()
return marked_count return marked_count

View File

@@ -8,7 +8,6 @@ from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone from datetime import datetime, timezone
from aiogram import Bot from aiogram import Bot
from aiogram.types import Message from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis import redis.asyncio as redis
@@ -16,6 +15,7 @@ import redis.asyncio as redis
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
from .config import REDIS_URL, ADMIN_IDS from .config import REDIS_URL, ADMIN_IDS
from .database import async_session_maker from .database import async_session_maker
from .delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -93,6 +93,7 @@ class BroadcastService:
BATCH_SIZE = 30 # Сообщений в пакете BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды) BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
MAX_RETRY_AFTER = 30
def __init__(self): def __init__(self):
self.redis_queue = RedisQueue() self.redis_queue = RedisQueue()
@@ -131,29 +132,9 @@ class BroadcastService:
error_type: Тип ошибки error_type: Тип ошибки
error_message: Сообщение об ошибке error_message: Сообщение об ошибке
""" """
# Проверяем, есть ли уже запись await delivery_service.mark_user_blocked(
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id) session, telegram_id, error_type, error_message
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}")
async def unblock_user(self, session: AsyncSession, telegram_id: int): async def unblock_user(self, session: AsyncSession, telegram_id: int):
""" """
@@ -163,23 +144,15 @@ class BroadcastService:
session: Сессия БД session: Сессия БД
telegram_id: Telegram ID пользователя telegram_id: Telegram ID пользователя
""" """
stmt = select(BlockedUser).where( await delivery_service.unblock_user(session, telegram_id)
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} разблокирован")
async def send_message_to_user( async def send_message_to_user(
self, self,
bot: Bot, bot: Bot,
user: User, user: User,
message: Message message: Message,
retry: bool = True,
skip_block_check: bool = False,
) -> Tuple[bool, Optional[str]]: ) -> Tuple[bool, Optional[str]]:
""" """
Отправить сообщение пользователю с обработкой ошибок Отправить сообщение пользователю с обработкой ошибок
@@ -192,86 +165,43 @@ class BroadcastService:
Returns: Returns:
Tuple[bool, Optional[str]]: (успех, тип_ошибки) Tuple[bool, Optional[str]]: (успех, тип_ошибки)
""" """
try: if message.text:
# Проверяем, не заблокирован ли пользователь send_call = lambda: bot.send_message(
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(
user.telegram_id, user.telegram_id,
message.text, message.text,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.photo: elif message.photo:
await bot.send_photo( send_call = lambda: bot.send_photo(
user.telegram_id, user.telegram_id,
photo=message.photo[-1].file_id, photo=message.photo[-1].file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.video: elif message.video:
await bot.send_video( send_call = lambda: bot.send_video(
user.telegram_id, user.telegram_id,
video=message.video.file_id, video=message.video.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
elif message.document: elif message.document:
await bot.send_document( send_call = lambda: bot.send_document(
user.telegram_id, user.telegram_id,
document=message.document.file_id, document=message.document.file_id,
caption=message.caption, caption=message.caption,
parse_mode="Markdown" parse_mode="Markdown"
) )
else: else:
# Копируем сообщение как есть send_call = lambda: message.copy_to(user.telegram_id)
await message.copy_to(user.telegram_id)
result = await delivery_service.send_with_guard(
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее) user.telegram_id,
async with async_session_maker() as session: send_call,
await self.unblock_user(session, user.telegram_id) retry=retry,
skip_block_check=skip_block_check,
return True, None )
return result.success, None if result.success else result.status
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"
async def broadcast_to_users( async def broadcast_to_users(
self, self,
@@ -307,6 +237,8 @@ class BroadcastService:
await session.refresh(broadcast_log) await session.refresh(broadcast_log)
log_id = broadcast_log.id log_id = broadcast_log.id
users_prefiltered = False
# Получаем список пользователей # Получаем список пользователей
if users is None: if users is None:
async with async_session_maker() as session: 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 = [u for u in all_users if u.telegram_id not in blocked_ids]
users_prefiltered = True
total_users = len(users) total_users = len(users)
success_count = 0 success_count = 0
@@ -337,7 +270,11 @@ class BroadcastService:
# Отправляем пакет # Отправляем пакет
tasks = [] tasks = []
for user in batch: 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) results = await asyncio.gather(*tasks, return_exceptions=True)

View File

@@ -11,9 +11,18 @@ if not BOT_TOKEN:
# База данных # База данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") 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
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") 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 = [] ADMIN_IDS = []
@@ -29,4 +38,4 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Настройки бота # Настройки бота
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей

View File

@@ -8,12 +8,34 @@ load_dotenv()
# Конфигурация базы данных # Конфигурация базы данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") 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( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=True, # Логирование SQL запросов **engine_kwargs,
future=True,
) )
# Создаем фабрику сессий # Создаем фабрику сессий
@@ -38,4 +60,4 @@ async def init_db():
async def close_db(): async def close_db():
"""Закрытие соединения с базой данных""" """Закрытие соединения с базой данных"""
await engine.dispose() await engine.dispose()

185
src/core/delivery.py Normal file
View 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()

View File

@@ -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 sqlalchemy.orm import relationship
from datetime import datetime, timezone from datetime import datetime, timezone
from .database import Base from .database import Base
@@ -8,6 +8,9 @@ import secrets
class User(Base): class User(Base):
"""Модель пользователя с регистрацией""" """Модель пользователя с регистрацией"""
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
Index("ix_users_registered_activity", "is_registered", "last_activity"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True) telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
@@ -114,6 +117,10 @@ class Lottery(Base):
class Participation(Base): class Participation(Base):
"""Модель участия в розыгрыше""" """Модель участия в розыгрыше"""
__tablename__ = "participations" __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) id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
@@ -136,6 +143,10 @@ class Participation(Base):
class Winner(Base): class Winner(Base):
"""Модель победителя розыгрыша""" """Модель победителя розыгрыша"""
__tablename__ = "winners" __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) id = Column(Integer, primary_key=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
@@ -271,6 +282,9 @@ class BroadcastChannel(Base):
class BlockedUser(Base): class BlockedUser(Base):
"""Пользователи, которые заблокировали бота или недоступны""" """Пользователи, которые заблокировали бота или недоступны"""
__tablename__ = "blocked_users" __tablename__ = "blocked_users"
__table_args__ = (
Index("ix_blocked_users_lookup", "telegram_id", "error_type", "is_active"),
)
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True) telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)

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

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

View File

@@ -5,6 +5,7 @@ from .models import User, Account, Winner, WinnerVerification
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import secrets import secrets
from ..utils.account_utils import format_account_number
class RegistrationService: class RegistrationService:
@@ -78,6 +79,10 @@ class AccountService:
account_number: str account_number: str
) -> Account: ) -> Account:
"""Создать новый счет для пользователя по номеру клубной карты""" """Создать новый счет для пользователя по номеру клубной карты"""
formatted_account = format_account_number(account_number)
if not formatted_account:
raise ValueError(f"Некорректный формат счета: {account_number}")
# Находим владельца по клубной карте # Находим владельца по клубной карте
user_result = await session.execute( user_result = await session.execute(
select(User).where(User.club_card_number == club_card_number) select(User).where(User.club_card_number == club_card_number)
@@ -89,14 +94,14 @@ class AccountService:
# Проверяем, не существует ли уже такой счет # Проверяем, не существует ли уже такой счет
existing = await session.execute( 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(): if existing.scalar_one_or_none():
raise ValueError(f"Счет {account_number} уже существует") raise ValueError(f"Счет {formatted_account} уже существует")
# Создаем счет # Создаем счет
account = Account( account = Account(
account_number=account_number, account_number=formatted_account,
owner_id=user.id, owner_id=user.id,
is_active=True is_active=True
) )
@@ -112,10 +117,14 @@ class AccountService:
account_number: str account_number: str
) -> Optional[User]: ) -> Optional[User]:
"""Найти владельца счета""" """Найти владельца счета"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return None
result = await session.execute( result = await session.execute(
select(Account).where( select(Account).where(
and_( and_(
Account.account_number == account_number, Account.account_number == formatted_account,
Account.is_active == True Account.is_active == True
) )
) )
@@ -150,8 +159,12 @@ class AccountService:
account_number: str account_number: str
) -> bool: ) -> bool:
"""Деактивировать счет""" """Деактивировать счет"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return False
result = await session.execute( 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() account = result.scalar_one_or_none()

View File

@@ -1,10 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession 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 sqlalchemy.orm import selectinload
from .models import User, Lottery, Participation, Winner, Account from .models import User, Lottery, Participation, Winner, Account
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from ..utils.account_utils import validate_account_number, format_account_number from ..utils.account_utils import validate_account_number, format_account_number
import random import random
from types import SimpleNamespace
class UserService: class UserService:
@@ -129,27 +130,39 @@ class UserService:
@staticmethod @staticmethod
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool: 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) formatted_number = format_account_number(account_number)
if not formatted_number: if not formatted_number:
return False return False
# Проверяем уникальность номера user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
return False
existing = await session.execute( existing = await session.execute(
select(User).where(User.account_number == formatted_number) select(Account).where(Account.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)
) )
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() await session.commit()
return result.rowcount > 0 return True
@staticmethod @staticmethod
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]: 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) formatted_number = format_account_number(account_number)
if not formatted_number: if not formatted_number:
return None 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 @staticmethod
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]: 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) select(User).where(User.club_card_number == club_card_number)
) )
return result.scalar_one_or_none() 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 @staticmethod
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]: async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
"""Поиск пользователей по части номера счета""" """Поиск пользователей по части номера счета"""
@@ -189,11 +212,17 @@ class UserService:
return [] return []
result = await session.execute( result = await session.execute(
select(User).where( select(User, Account.account_number)
User.account_number.like(f'%{clean_pattern}%') .join(Account, Account.owner_id == User.id)
).limit(20) .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: class LotteryService:
@@ -307,15 +336,25 @@ class LotteryService:
participants = [] participants = []
for p in lottery.participations: for p in lottery.participations:
if p.user: 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: else:
# Создаем временный объект для участников без пользователя # Создаем временный объект для участников без пользователя
# Храним только номер счета # Храним только номер счета
participants.append(type('obj', (object,), { participants.append(SimpleNamespace(
'id': None, id=None,
'telegram_id': None, telegram_id=None,
'account_number': p.account_number username=None,
})()) first_name=None,
last_name=None,
account_number=p.account_number
))
logger.info(f"conduct_draw: участников {len(participants)}") logger.info(f"conduct_draw: участников {len(participants)}")
if not participants: if not participants:
@@ -503,13 +542,22 @@ class ParticipationService:
@staticmethod @staticmethod
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]: 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: if limit:
query = query.offset(offset).limit(limit) query = query.offset(offset).limit(limit)
result = await session.execute(query) 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 @staticmethod
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]: async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
@@ -525,11 +573,9 @@ class ParticipationService:
@staticmethod @staticmethod
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int: async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
"""Получить количество участников в розыгрыше""" """Получить количество участников в розыгрыше"""
result = await session.execute( return await session.scalar(
select(Participation) select(func.count(Participation.id)).where(Participation.lottery_id == lottery_id)
.where(Participation.lottery_id == lottery_id)
) )
return len(result.scalars().all())
@staticmethod @staticmethod
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]: async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
@@ -595,9 +641,6 @@ class ParticipationService:
@staticmethod @staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]: 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 = { results = {
"added": 0, "added": 0,
"skipped": 0, "skipped": 0,
@@ -610,39 +653,30 @@ class ParticipationService:
account_input = account_input.strip() account_input = account_input.strip()
if not account_input: if not account_input:
continue continue
logger.info(f"DEBUG: Processing account_input={account_input!r}")
try: try:
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета # Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split() parts = account_input.split()
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
if len(parts) == 2: if len(parts) == 2:
card_number = parts[0] # Номер клубной карты card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета account_number = parts[1] # Номер счета
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
elif len(parts) == 1: elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета # Если нет пробела, считаем что это просто номер счета
card_number = None card_number = None
account_number = parts[0] account_number = parts[0]
logger.info(f"DEBUG: 1 part - account={account_number!r}")
else: else:
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}") results["errors"].append(f"Неверный формат: {account_input}")
continue continue
# Валидируем и форматируем номер счета # Валидируем и форматируем номер счета
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
formatted_account = format_account_number(account_number) formatted_account = format_account_number(account_number)
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
if not formatted_account: if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else "" card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}") results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
logger.error(f"DEBUG: Format failed for {account_number!r}")
continue continue
# Ищем владельца счёта через таблицу Account # Ищем владельца счёта через таблицу Account
@@ -687,7 +721,6 @@ class ParticipationService:
account_number=formatted_account account_number=formatted_account
) )
session.add(participation) session.add(participation)
await session.commit()
results["added"] += 1 results["added"] += 1
detail = f"{user.first_name} ({formatted_account})" detail = f"{user.first_name} ({formatted_account})"
@@ -697,6 +730,9 @@ class ParticipationService:
except Exception as e: except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}") results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
if results["added"]:
await session.commit()
return results return results
@@ -759,7 +795,6 @@ class ParticipationService:
if participation: if participation:
await session.delete(participation) await session.delete(participation)
await session.commit()
results["removed"] += 1 results["removed"] += 1
detail = f"{user.first_name} ({formatted_account})" detail = f"{user.first_name} ({formatted_account})"
@@ -775,14 +810,15 @@ class ParticipationService:
except Exception as e: except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}") results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
if results["removed"]:
await session.commit()
return results return results
@staticmethod @staticmethod
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]: async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
"""Статистика участника""" """Статистика участника"""
from sqlalchemy import func
# Количество участий # Количество участий
participations_count = await session.scalar( participations_count = await session.scalar(
select(func.count(Participation.id)).where(Participation.user_id == user_id) select(func.count(Participation.id)).where(Participation.user_id == user_id)
@@ -804,4 +840,42 @@ class ParticipationService:
"participations_count": participations_count, "participations_count": participations_count,
"wins_count": wins_count, "wins_count": wins_count,
"last_participation": last_participation.created_at if last_participation else None "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

View File

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

View File

@@ -6,7 +6,6 @@ import asyncio
import json import json
from ..core.database import async_session_maker from ..core.database import async_session_maker
from ..core.services import LotteryService, ParticipationService from ..core.services import LotteryService, ParticipationService
from ..core.models import Lottery, User
async def conduct_lottery_draw(lottery_id: int): async def conduct_lottery_draw(lottery_id: int):
"""Проводим розыгрыш для указанного ID""" """Проводим розыгрыш для указанного ID"""
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
# Выводим список участников # Выводим список участников
print("\n👥 Список участников:") print("\n👥 Список участников:")
for i, user in enumerate(participants, 1): for i, user in enumerate(participants, 1):
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов'] username = f"@{user.username}" if user.username else "без username"
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)") print(f" {i}. {user.first_name or 'Без имени'} ({username})")
# Проводим розыгрыш # Проводим розыгрыш
print(f"\n🎲 Проводим розыгрыш...") print(f"\n🎲 Проводим розыгрыш...")
@@ -48,14 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
if winners: if winners:
print(f"\n🎉 Победители определены:") print(f"\n🎉 Победители определены:")
for i, winner_data in enumerate(winners, 1): for place, winner_data in winners.items():
user = winner_data['user'] user = winner_data['user']
prize = winner_data['prize'] 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}") print(f" 💎 Приз: {prize}")
# Обновляем статус розыгрыша await session.commit()
await LotteryService.set_lottery_completed(session, lottery_id, True)
print(f"\n✅ Розыгрыш завершен и помечен как завершенный") print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
else: else:
@@ -99,4 +99,4 @@ async def main():
print("❌ Неверный ID розыгрыша") print("❌ Неверный ID розыгрыша")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

@@ -3,7 +3,7 @@
""" """
import asyncio import asyncio
from ..core.database import async_session_maker, init_db 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 from ..utils.admin_utils import AdminUtils, ReportGenerator
@@ -87,17 +87,17 @@ async def demo_admin_features():
participants_added = 0 participants_added = 0
for user in users: 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 participants_added += 1
# Во второй - половину # Во второй - половину
if user.id % 2 == 0: 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 participants_added += 1
# В третий - треть # В третий - треть
if user.id % 3 == 0: 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 participants_added += 1
print(f"✅ Добавлено {participants_added} участий") print(f"✅ Добавлено {participants_added} участий")
@@ -127,6 +127,7 @@ async def demo_admin_features():
# Первый розыгрыш # Первый розыгрыш
results1 = await LotteryService.conduct_draw(session, lottery1.id) results1 = await LotteryService.conduct_draw(session, lottery1.id)
await session.commit()
print(f" 🎉 {lottery1.title} - результаты:") print(f" 🎉 {lottery1.title} - результаты:")
for place, winner_info in results1.items(): for place, winner_info in results1.items():
user = winner_info['user'] user = winner_info['user']
@@ -135,6 +136,7 @@ async def demo_admin_features():
# Второй розыгрыш # Второй розыгрыш
results2 = await LotteryService.conduct_draw(session, lottery2.id) results2 = await LotteryService.conduct_draw(session, lottery2.id)
await session.commit()
print(f" 🚗 {lottery2.title} - результаты:") print(f" 🚗 {lottery2.title} - результаты:")
for place, winner_info in results2.items(): for place, winner_info in results2.items():
user = winner_info['user'] user = winner_info['user']
@@ -188,4 +190,4 @@ async def demo_admin_features():
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(demo_admin_features()) asyncio.run(demo_admin_features())

View File

@@ -19,6 +19,7 @@ async def conduct_simple_draw():
try: try:
# Проводим розыгрыш # Проводим розыгрыш
winners = await LotteryService.conduct_draw(session, lottery_id) winners = await LotteryService.conduct_draw(session, lottery_id)
await session.commit()
print(f"🎉 Розыгрыш проведен!") print(f"🎉 Розыгрыш проведен!")
print(f"📊 Результат: {winners}") print(f"📊 Результат: {winners}")
@@ -29,4 +30,4 @@ async def conduct_simple_draw():
traceback.print_exc() traceback.print_exc()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(conduct_simple_draw()) asyncio.run(conduct_simple_draw())

View File

@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
from ..utils.account_utils import mask_account_number 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: 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': elif display_type == 'account_number':
# Отображаем номер клиентского счета # Отображаем номер клиентского счета
if not user.account_number: account_number = _get_user_account_number(user)
if not account_number:
return "Счёт не указан" return "Счёт не указан"
if show_sensitive_data: if show_sensitive_data:
# Для админов показываем полный номер # Для админов показываем полный номер
return f"Счёт: {user.account_number}" return f"Счёт: {account_number}"
else: 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}" return f"Счёт: {masked}"
else: else:
@@ -113,4 +129,4 @@ def validate_display_type(display_type: str) -> bool:
Returns: Returns:
bool: True если тип корректен bool: True если тип корректен
""" """
return display_type in ['username', 'chat_id', 'account_number'] return display_type in ['username', 'chat_id', 'account_number']

View File

@@ -15,7 +15,8 @@ class AccountParticipationService:
async def add_account_to_lottery( async def add_account_to_lottery(
session: AsyncSession, session: AsyncSession,
lottery_id: int, lottery_id: int,
account_number: str account_number: str,
commit: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Добавить счет в розыгрыш. Добавить счет в розыгрыш.
@@ -91,7 +92,10 @@ class AccountParticipationService:
account_id=account_record.id if account_record else None account_id=account_record.id if account_record else None
) )
session.add(participation) session.add(participation)
await session.commit() if commit:
await session.commit()
else:
await session.flush()
card_info = f" (карта: {card_number})" if card_number else "" card_info = f" (карта: {card_number})" if card_number else ""
return { return {
@@ -120,7 +124,7 @@ class AccountParticipationService:
for account in account_numbers: for account in account_numbers:
result = await AccountParticipationService.add_account_to_lottery( result = await AccountParticipationService.add_account_to_lottery(
session, lottery_id, account session, lottery_id, account, commit=False
) )
if result["success"]: if result["success"]:
@@ -132,6 +136,8 @@ class AccountParticipationService:
results["skipped_accounts"].append(account) results["skipped_accounts"].append(account)
results["errors"].append(result["message"]) results["errors"].append(result["message"])
results["details"].append(f"{result['message']}") results["details"].append(f"{result['message']}")
await session.commit()
return results return results
@@ -139,7 +145,8 @@ class AccountParticipationService:
async def remove_account_from_lottery( async def remove_account_from_lottery(
session: AsyncSession, session: AsyncSession,
lottery_id: int, lottery_id: int,
account_number: str account_number: str,
commit: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Удалить счет из розыгрыша""" """Удалить счет из розыгрыша"""
formatted_account = format_account_number(account_number) formatted_account = format_account_number(account_number)
@@ -164,7 +171,10 @@ class AccountParticipationService:
} }
await session.delete(participation) await session.delete(participation)
await session.commit() if commit:
await session.commit()
else:
await session.flush()
return { return {
"success": True, "success": True,
@@ -216,13 +226,21 @@ class AccountParticipationService:
pattern: Паттерн поиска (например "11-22" или "33") pattern: Паттерн поиска (например "11-22" или "33")
limit: Максимальное количество результатов limit: Максимальное количество результатов
""" """
# Получаем все счета розыгрыша digits_pattern = ''.join(c for c in pattern if c.isdigit())
all_accounts = await AccountParticipationService.get_lottery_accounts( if not digits_pattern:
session, lottery_id 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 [account for account in result.scalars().all() if account]
# Ищем совпадения
return search_accounts_by_pattern(pattern, all_accounts)[:limit]
@staticmethod @staticmethod
async def set_account_as_winner( async def set_account_as_winner(

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,6 +7,7 @@ from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional from typing import Optional
from src.core.p2p_services import P2PMessageService 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.models import User
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.delivery import delivery_service
router = Router(name='p2p_chat_router') router = Router(name='p2p_chat_router')
@@ -124,9 +126,13 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
await callback.answer() await callback.answer()
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей кроме себя result = await session.execute(
users = await UserService.get_all_users(session) select(User)
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered] .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: if not users:
await callback.message.edit_text("❌ Нет доступных пользователей для общения") await callback.message.edit_text("❌ Нет доступных пользователей для общения")
@@ -134,7 +140,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
# Создаём кнопки с пользователями (по 1 на строку) # Создаём кнопки с пользователями (по 1 на строку)
buttons = [] buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице for user in users:
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown" display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
if user.club_card_number: if user.club_card_number:
display_name += f" (карта: {user.club_card_number})" display_name += f" (карта: {user.club_card_number})"
@@ -411,49 +417,58 @@ async def handle_p2p_message(message: Message, state: FSMContext):
file_id = message.document.file_id file_id = message.document.file_id
text = message.caption text = message.caption
# Отправляем сообщение получателю async def send_to_recipient():
try:
if message_type == "text": if message_type == "text":
sent = await message.bot.send_message( return await message.bot.send_message(
recipient_telegram_id, recipient_telegram_id,
f"<b>{sender_name}</b>\n\n{text}", f"<b>{sender_name}</b>\n\n{text}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "photo": elif message_type == "photo":
sent = await message.bot.send_photo( return await message.bot.send_photo(
recipient_telegram_id, recipient_telegram_id,
photo=file_id, photo=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}" , caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "video": elif message_type == "video":
sent = await message.bot.send_video( return await message.bot.send_video(
recipient_telegram_id, recipient_telegram_id,
video=file_id, video=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
elif message_type == "document": elif message_type == "document":
sent = await message.bot.send_document( return await message.bot.send_document(
recipient_telegram_id, recipient_telegram_id,
document=file_id, document=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}", caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML" parse_mode="HTML"
) )
# Сохраняем в БД return await message.copy_to(recipient_telegram_id)
await P2PMessageService.send_message(
session, delivery = await delivery_service.send_with_guard(
sender_id=sender.id, recipient_telegram_id,
recipient_id=recipient_id, send_to_recipient,
message_type=message_type, )
text=text, if not delivery.success:
file_id=file_id, if delivery.status in {"blocked_bot", "deactivated", "not_found", "chat_not_found"}:
sender_message_id=message.message_id, await message.answer("❌ Получатель недоступен: он мог заблокировать бота")
recipient_message_id=sent.message_id else:
) await message.answer("Не удалось доставить сообщение. Попробуйте позже")
return
await message.answer("✅ Сообщение доставлено")
# Сохраняем в БД только реально доставленное сообщение.
except Exception as e: await P2PMessageService.send_message(
await message.answer(f"Не удалось доставить сообщение: {e}") 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("✅ Сообщение доставлено")

View File

@@ -13,6 +13,7 @@ from src.core.services import LotteryService
from src.core.models import User, Winner from src.core.models import User, Winner
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.permissions import admin_only from src.core.permissions import admin_only
from src.core.delivery import delivery_service
router = Router() router = Router()
@@ -276,18 +277,26 @@ async def redraw_lottery(message: Message):
)] )]
]) ])
try: delivery = await delivery_service.send_with_guard(
await message.bot.send_message( owner.telegram_id,
owner.telegram_id, lambda: message.bot.send_message(
notification_message, owner.telegram_id,
reply_markup=keyboard, notification_message,
parse_mode="Markdown" reply_markup=keyboard,
) parse_mode="Markdown"
),
)
if delivery.success:
new_winner.is_notified = True new_winner.is_notified = True
await session.commit() await session.commit()
except: else:
pass import logging
logging.getLogger(__name__).warning(
"Не удалось уведомить нового победителя %s: %s",
owner.telegram_id,
delivery.status,
)
# Формируем отчет для админа # Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n" text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
@@ -416,10 +425,12 @@ async def confirm_winner_callback(callback_query):
from aiogram import Bot from aiogram import Bot
from src.core.config import BOT_TOKEN from src.core.config import BOT_TOKEN
bot = Bot(token=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: except Exception as e:
import logging import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}") logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True) await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)

View File

@@ -5,15 +5,19 @@ from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.types import TelegramObject, Update, Message, CallbackQuery
import logging import logging
import time
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.activity_service import ActivityService from src.core.activity_service import ActivityService
from src.core.config import ACTIVITY_UPDATE_INTERVAL_SECONDS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ActivityMiddleware(BaseMiddleware): class ActivityMiddleware(BaseMiddleware):
"""Middleware для обновления last_activity при каждом взаимодействии""" """Middleware для обновления last_activity при каждом взаимодействии"""
_last_updates: Dict[int, float] = {}
async def __call__( async def __call__(
self, self,
@@ -34,19 +38,36 @@ class ActivityMiddleware(BaseMiddleware):
elif event.callback_query and event.callback_query.from_user: elif event.callback_query and event.callback_query.from_user:
telegram_id = event.callback_query.from_user.id telegram_id = event.callback_query.from_user.id
# Обновляем активность если есть telegram_id # Обновляем активность не чаще заданного интервала.
if telegram_id: if telegram_id and self._should_update(telegram_id):
try: try:
async with async_session_maker() as session: async with async_session_maker() as session:
# Обновляем активность reactivated = await ActivityService.update_activity_and_reactivate(session, telegram_id)
await ActivityService.update_user_activity(session, telegram_id) if reactivated is None:
self._last_updates.pop(telegram_id, None)
# Проверяем, не был ли пользователь заблокирован за неактивность return await handler(event, data)
# Если был - реактивируем if reactivated:
await ActivityService.reactivate_user(session, telegram_id) logger.info(f"Пользователь {telegram_id} реактивирован")
except Exception as e: except Exception as e:
self._last_updates.pop(telegram_id, None)
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}") logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
# Вызываем следующий обработчик # Вызываем следующий обработчик
return await handler(event, data) 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

View File

@@ -11,6 +11,7 @@ from ..core.models import Winner, User
from ..core.services import LotteryService from ..core.services import LotteryService
from ..core.registration_services import AccountService, WinnerNotificationService from ..core.registration_services import AccountService, WinnerNotificationService
from ..core.config import ADMIN_IDS from ..core.config import ADMIN_IDS
from ..core.delivery import delivery_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,19 +80,28 @@ async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int)
)] )]
]) ])
# Отправляем уведомление с кнопкой delivery = await delivery_service.send_with_guard(
await bot.send_message( owner.telegram_id,
owner.telegram_id, lambda: bot.send_message(
message, owner.telegram_id,
reply_markup=keyboard, message,
parse_mode="Markdown" reply_markup=keyboard,
parse_mode="Markdown"
),
) )
# Отмечаем, что уведомление отправлено if delivery.success:
winner.is_notified = True # Отмечаем, что уведомление отправлено
await session.commit() winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
owner.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id") 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, user.telegram_id,
message, lambda: bot.send_message(
reply_markup=keyboard user.telegram_id,
message,
reply_markup=keyboard
),
) )
winner.is_notified = True if delivery.success:
await session.commit() winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
else:
logger.warning(
"⚠️ Уведомление победителю %s не отправлено: %s",
user.telegram_id,
delivery.status,
)
else: else:
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id") logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")

View File

@@ -151,18 +151,19 @@ class AsyncTaskManager:
task = await asyncio.wait_for(self.task_queue.get(), timeout=1.0) task = await asyncio.wait_for(self.task_queue.get(), timeout=1.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
# Получаем семафоры try:
async with self.worker_semaphore: # Получаем семафоры
user_semaphore = self._get_user_semaphore(task.user_id) async with self.worker_semaphore:
if user_semaphore is not None: user_semaphore = self._get_user_semaphore(task.user_id)
async with user_semaphore: 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) await self._execute_task(worker_name, task)
else: finally:
await self._execute_task(worker_name, task) # Отмечаем задачу как обработанную даже при ошибке выполнения.
self.task_queue.task_done()
# Отмечаем задачу как выполненную
self.task_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug(f"Воркер {worker_name} отменён") logger.debug(f"Воркер {worker_name} отменён")
@@ -265,4 +266,4 @@ class AsyncTaskManager:
task_manager = AsyncTaskManager( task_manager = AsyncTaskManager(
max_workers=15, # Максимум воркеров max_workers=15, # Максимум воркеров
max_user_concurrent=5 # Максимум задач на пользователя max_user_concurrent=5 # Максимум задач на пользователя
) )

101
test_chat_fix.md Normal file
View File

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

View File

@@ -26,7 +26,7 @@ async def test_comprehensive_features():
for i in range(5): for i in range(5):
# Генерируем уникальные данные # Генерируем уникальные данные
unique_id = random.randint(10000000, 99999999) 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( user = await UserService.get_or_create_user(
session, session,
@@ -52,7 +52,7 @@ async def test_comprehensive_features():
title="Тест массовых операций", title="Тест массовых операций",
description="Розыгрыш для тестирования массовых операций", description="Розыгрыш для тестирования массовых операций",
prizes=["Приз 1", "Приз 2"], prizes=["Приз 1", "Приз 2"],
creator_id=1 creator_id=test_users[0].id
) )
print(f"✅ Розыгрыш создан: {lottery.title}") print(f"✅ Розыгрыш создан: {lottery.title}")

View File

@@ -27,7 +27,7 @@ async def test_basic_functionality():
# 1. Тест создания пользователя с номером счёта # 1. Тест создания пользователя с номером счёта
print("\n1. 👤 Создание пользователя с номером счёта:") 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( user = await UserService.get_or_create_user(
session, session,
telegram_id=999999999, telegram_id=999999999,
@@ -49,7 +49,7 @@ async def test_basic_functionality():
print("\n2. 📋 Тестирование валидации номеров:") print("\n2. 📋 Тестирование валидации номеров:")
test_cases = [ test_cases = [
("12-34-56-78-90-12-34-56", True), ("12-34-56-78-90-12-34", True),
("invalid-number", False), ("invalid-number", False),
("12345678901234567890", False) ("12345678901234567890", False)
] ]
@@ -84,7 +84,7 @@ async def test_basic_functionality():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
display_types = ['username', 'chat_id', 'account_number'] display_types = ['username', 'chat_id', 'account_number']

View File

@@ -20,7 +20,7 @@ async def test_features():
async with async_session_maker() as session: async with async_session_maker() as session:
# Генерируем уникальные тестовые данные # Генерируем уникальные тестовые данные
unique_id = random.randint(1000000, 9999999) 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"🆔 Используем уникальный ID: {unique_id}")
print(f"🔢 Используем номер счёта: {test_account}") print(f"🔢 Используем номер счёта: {test_account}")
@@ -44,9 +44,7 @@ async def test_features():
if success: if success:
print(f"✅ Номер счёта {test_account} установлен") print(f"✅ Номер счёта {test_account} установлен")
# Обновляем данные пользователя print(f"✅ Подтверждено: {test_account}")
await session.refresh(user)
print(f"✅ Подтверждено: {user.account_number}")
else: else:
print(f"Не удалось установить номер счёта") print(f"Не удалось установить номер счёта")
return return
@@ -72,7 +70,7 @@ async def test_features():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
# Тестируем все типы отображения # Тестируем все типы отображения

View 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)

View File

@@ -30,14 +30,14 @@ async def test_account_functionality():
'username': 'client1', 'username': 'client1',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Первый', 'last_name': 'Первый',
'account': '12-34-56-78-90-12-34-56' 'account': '12-34-56-78-90-12-34'
}, },
{ {
'telegram_id': 777000002, 'telegram_id': 777000002,
'username': 'client2', 'username': 'client2',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Второй', 'last_name': 'Второй',
'account': '11-22-33-44-55-66-77-88' 'account': '11-22-33-44-55-66-77'
}, },
{ {
'telegram_id': 777000003, 'telegram_id': 777000003,
@@ -78,9 +78,9 @@ async def test_account_functionality():
print("\n📋 Тестирование валидации номеров счетов:") print("\n📋 Тестирование валидации номеров счетов:")
test_numbers = [ test_numbers = [
("12-34-56-78-90-12-34-56", True), # Корректный ("12-34-56-78-90-12-34", True), # Корректный
("12345678901234567890123456", False), # Без дефисов ("12345678901234", False), # Без дефисов
("12-34-56-78-90-12-34", False), # Короткий ("12-34-56-78-90-12", False), # Короткий
("12-34-56-78-90-12-34-56-78", False), # Длинный ("12-34-56-78-90-12-34-56-78", False), # Длинный
("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы ("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы
("", False) # Пустой ("", False) # Пустой
@@ -93,7 +93,7 @@ async def test_account_functionality():
# Тест маскирования # Тест маскирования
print("\n🎭 Тестирование маскирования номеров:") 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]: for digits in [4, 6, 8]:
masked = mask_account_number(test_account, show_last_digits=digits) masked = mask_account_number(test_account, show_last_digits=digits)
print(f"Показать последние {digits} цифр: {masked}") print(f"Показать последние {digits} цифр: {masked}")
@@ -103,7 +103,7 @@ async def test_account_functionality():
async with async_session_maker() as session: 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: if user:
print(f"✅ Найден пользователь по полному номеру: {user.first_name}") print(f"✅ Найден пользователь по полному номеру: {user.first_name}")
@@ -123,18 +123,21 @@ async def test_display_types():
print("=" * 50) print("=" * 50)
async with async_session_maker() as session: 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( lottery = await LotteryService.create_lottery(
session, session,
title="Тест отображения победителей", title="Тест отображения победителей",
description="Розыгрыш для тестирования различных типов отображения", description="Розыгрыш для тестирования различных типов отображения",
prizes=["Первый приз", "Второй приз"], 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: if user and lottery:
print(f"👤 Тестируем отображение для пользователя: {user.first_name}") print(f"👤 Тестируем отображение для пользователя: {user.first_name}")
print(f"💳 Номер счёта: {user.account_number}") print(f"💳 Номер счёта: {user.account_number}")