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

View File

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

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

View File

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

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.filters import Command
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.context import FSMContext
from src.filters.case_insensitive import CaseInsensitiveCommand
from src.core.config import BOT_TOKEN
from src.core.config import BOT_TOKEN, LOG_LEVEL, REDIS_URL, USE_REDIS_STORAGE
from src.core.database import async_session_maker
from src.core.scheduler import bot_scheduler
from src.container import container
@@ -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.help_handlers import router as help_router
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
from src.utils.task_manager import task_manager
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Создание бота и диспетчера
bot = Bot(token=BOT_TOKEN)
storage = MemoryStorage()
def create_fsm_storage():
if not USE_REDIS_STORAGE:
logger.warning("FSM storage: используется MemoryStorage")
return MemoryStorage()
try:
logger.info("FSM storage: используется RedisStorage")
return RedisStorage.from_url(REDIS_URL)
except Exception as e:
logger.error(f"Не удалось создать RedisStorage, используется MemoryStorage: {e}")
return MemoryStorage()
storage = create_fsm_storage()
dp = Dispatcher(storage=storage)
router = Router()
@@ -50,9 +67,9 @@ router = Router()
@dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов"""
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
logger.debug(f"Callback received: data={event.data!r}, user_id={event.from_user.id}")
result = await handler(event, data)
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
logger.debug(f"Callback handled: data={event.data!r}, result={result}")
return result
@@ -179,7 +196,7 @@ async def btn_my_code(message: Message):
await show_verification_code(message)
@router.message(F.text == "<EFBFBD> Мои логины")
@router.message(F.text == "📱 Мои логины")
async def btn_my_accounts(message: Message):
"""Обработчик кнопки 'Мои логины'"""
from src.handlers.registration_handlers import show_user_accounts
@@ -308,6 +325,9 @@ async def main():
# Запускаем планировщик задач
bot_scheduler.start()
logger.info("Планировщик задач запущен")
await task_manager.start()
logger.info("Менеджер фоновых задач запущен")
# Запускаем polling
try:
@@ -318,6 +338,9 @@ async def main():
finally:
# Останавливаем планировщик
bot_scheduler.shutdown()
await task_manager.stop()
if hasattr(storage, "close"):
await storage.close()
await bot.session.close()
@@ -327,4 +350,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
logger.info("Бот остановлен пользователем")
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:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('blocked_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('error_type', sa.String(length=100), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('attempt_count', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
op.create_table('broadcast_channels',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chat_id', sa.BigInteger(), nullable=False),
sa.Column('chat_type', sa.String(length=20), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('added_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
op.create_table('broadcast_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
sa.Column('target_id', sa.BigInteger(), nullable=True),
sa.Column('message_type', sa.String(length=20), nullable=False),
sa.Column('message_text', sa.Text(), nullable=True),
sa.Column('file_id', sa.String(length=255), nullable=True),
sa.Column('total_recipients', sa.Integer(), nullable=True),
sa.Column('success_count', sa.Integer(), nullable=True),
sa.Column('failed_count', sa.Integer(), nullable=True),
sa.Column('blocked_count', sa.Integer(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=True))
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
# Duplicate autogenerated migration. The real changes are applied by
# 71376bb89294, 1f1631301809 and b4c435a7dc5f.
pass
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'last_activity')
op.drop_column('users', 'is_chat_banned')
op.drop_index(op.f('ix_broadcast_logs_status'), table_name='broadcast_logs')
op.drop_index(op.f('ix_broadcast_logs_broadcast_type'), table_name='broadcast_logs')
op.drop_table('broadcast_logs')
op.drop_index(op.f('ix_broadcast_channels_is_active'), table_name='broadcast_channels')
op.drop_index(op.f('ix_broadcast_channels_chat_id'), table_name='broadcast_channels')
op.drop_table('broadcast_channels')
op.drop_index(op.f('ix_blocked_users_telegram_id'), table_name='blocked_users')
op.drop_index(op.f('ix_blocked_users_is_active'), table_name='blocked_users')
op.drop_table('blocked_users')
# ### end Alembic commands ###
pass

View File

@@ -1,7 +1,7 @@
"""merge branches
Revision ID: merge_migration
Revises: cd31303a681c
Revises: cd31303a681c, 41aae82e631b
Create Date: 2026-02-18 04:02:12.000000
"""
@@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'merge_migration'
down_revision = 'cd31303a681c'
down_revision = ('cd31303a681c', '41aae82e631b')
branch_labels = 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
aiogram==3.16.0
aiohttp>=3.11.0
sqlalchemy==2.0.36
alembic==1.14.0
python-dotenv==1.0.1
asyncpg==0.30.0
aiosqlite==0.20.0
redis==5.2.1
aioredis==2.0.1
apscheduler==3.10.4
openpyxl==3.1.2
# Runtime target: Python 3.12+
aiogram==3.29.0
aiohttp==3.14.1
SQLAlchemy==2.0.51
alembic==1.18.5
python-dotenv==1.2.2
asyncpg==0.31.0
aiosqlite==0.22.1
redis==8.0.1
APScheduler==3.11.3
openpyxl==3.1.5
# Development / diagnostics
pytest==9.1.1
pytest-asyncio==1.4.0

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import logging
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
from src.interfaces.base import ILotteryRepository, IParticipationRepository
from src.core.config import ADMIN_IDS
from src.core.telegram_config import get_parse_mode
logger = logging.getLogger(__name__)
@@ -88,7 +89,7 @@ class BotController(IBotController):
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
return
text = "🎲 **Активные розыгрыши:**\n\n"
text = "🎲 <b>Активные розыгрыши:</b>\n\n"
for lottery in lotteries:
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
@@ -112,7 +113,7 @@ class BotController(IBotController):
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
parse_mode=get_parse_mode("inline_keyboard")
)
except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback
@@ -124,5 +125,5 @@ class BotController(IBotController):
await callback.message.answer(
text,
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 sqlalchemy import select, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from typing import List, Optional
import logging
from .models import User, BlockedUser
@@ -40,6 +40,34 @@ class ActivityService:
except Exception as e:
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
await session.rollback()
@staticmethod
async def update_activity_and_reactivate(session: AsyncSession, telegram_id: int) -> Optional[bool]:
"""Обновить активность и снять блокировку за неактивность одним коммитом."""
try:
now = datetime.now(timezone.utc)
await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(last_activity=now)
)
result = await session.execute(
update(BlockedUser)
.where(
and_(
BlockedUser.telegram_id == telegram_id,
BlockedUser.is_active == True,
)
)
.values(is_active=False, last_attempt_at=now)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Ошибка обновления активности/реактивации пользователя {telegram_id}: {e}")
await session.rollback()
return None
@staticmethod
async def get_inactive_users(
@@ -84,35 +112,45 @@ class ActivityService:
Количество помеченных пользователей
"""
try:
inactive_users = await ActivityService.get_inactive_users(session, days)
marked_count = 0
for user in inactive_users:
# Проверяем, не помечен ли уже
stmt = select(BlockedUser).where(
if days is None:
days = ActivityService.INACTIVITY_PERIOD_DAYS
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
already_blocked = (
select(BlockedUser.id)
.where(
and_(
BlockedUser.telegram_id == user.telegram_id,
BlockedUser.telegram_id == User.telegram_id,
BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True
BlockedUser.is_active == True,
)
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
# Создаем новую запись
blocked = BlockedUser(
telegram_id=user.telegram_id,
error_type='inactive',
error_message=f'User inactive for {days} days',
first_blocked_at=datetime.now(timezone.utc),
last_attempt_at=datetime.now(timezone.utc),
attempt_count=1,
is_active=True
.exists()
)
result = await session.execute(
select(User).where(
and_(
User.last_activity < cutoff_date,
User.is_registered == True,
~already_blocked,
)
session.add(blocked)
marked_count += 1
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
)
)
inactive_users = list(result.scalars().all())
marked_count = 0
now = datetime.now(timezone.utc)
for user in inactive_users:
session.add(BlockedUser(
telegram_id=user.telegram_id,
error_type='inactive',
error_message=f'User inactive for {days} days',
first_blocked_at=now,
last_attempt_at=now,
attempt_count=1,
is_active=True
))
marked_count += 1
await session.commit()
return marked_count

View File

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

View File

@@ -11,9 +11,18 @@ if not BOT_TOKEN:
# База данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
# Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
USE_REDIS_STORAGE = os.getenv("USE_REDIS_STORAGE", "true").lower() in {"1", "true", "yes", "on"}
# Активность пользователей
ACTIVITY_UPDATE_INTERVAL_SECONDS = int(os.getenv("ACTIVITY_UPDATE_INTERVAL_SECONDS", "300"))
# Администраторы
ADMIN_IDS = []
@@ -29,4 +38,4 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Настройки бота
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей

View File

@@ -8,12 +8,34 @@ load_dotenv()
# Конфигурация базы данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
SQL_ECHO = os.getenv("SQL_ECHO", "false").lower() in {"1", "true", "yes", "on"}
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
# Создаем асинхронный движок
engine_kwargs = {
"echo": SQL_ECHO,
"future": True,
}
if DATABASE_URL.startswith("postgresql+asyncpg"):
engine_kwargs.update(
{
"pool_pre_ping": True,
"pool_size": DB_POOL_SIZE,
"max_overflow": DB_MAX_OVERFLOW,
"pool_timeout": DB_POOL_TIMEOUT,
"pool_recycle": DB_POOL_RECYCLE,
"connect_args": {"timeout": DB_CONNECT_TIMEOUT},
}
)
engine = create_async_engine(
DATABASE_URL,
echo=True, # Логирование SQL запросов
future=True,
**engine_kwargs,
)
# Создаем фабрику сессий
@@ -38,4 +60,4 @@ async def init_db():
async def close_db():
"""Закрытие соединения с базой данных"""
await engine.dispose()
await engine.dispose()

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

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

View File

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

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

View File

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

View File

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

View File

@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
from ..utils.account_utils import mask_account_number
def _get_user_account_number(user: User) -> Optional[str]:
"""Вернуть номер счета из совместимого поля или уже загруженной связи."""
account_number = getattr(user, 'account_number', None)
if account_number:
return account_number
# Не инициируем lazy-load в async SQLAlchemy: берем только уже загруженную связь.
accounts = getattr(user, '__dict__', {}).get('accounts') or []
for account in accounts:
if getattr(account, 'is_active', True) and getattr(account, 'account_number', None):
return account.account_number
return None
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
"""
Форматирует отображение победителя в зависимости от настроек розыгрыша
@@ -33,15 +48,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
elif display_type == 'account_number':
# Отображаем номер клиентского счета
if not user.account_number:
account_number = _get_user_account_number(user)
if not account_number:
return "Счёт не указан"
if show_sensitive_data:
# Для админов показываем полный номер
return f"Счёт: {user.account_number}"
return f"Счёт: {account_number}"
else:
# Для публичного показа маскируем номер
masked = mask_account_number(user.account_number, show_last_digits=4)
masked = mask_account_number(account_number, show_last_digits=4)
return f"Счёт: {masked}"
else:
@@ -113,4 +129,4 @@ def validate_display_type(display_type: str) -> bool:
Returns:
bool: True если тип корректен
"""
return display_type in ['username', 'chat_id', 'account_number']
return display_type in ['username', 'chat_id', 'account_number']

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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