6 Commits
master ... V2

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
37 changed files with 1348 additions and 702 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

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 \

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:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-trevor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
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:-trevor}"]
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:

35
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
@@ -309,6 +326,9 @@ async def main():
bot_scheduler.start()
logger.info("Планировщик задач запущен")
await task_manager.start()
logger.info("Менеджер фоновых задач запущен")
# Запускаем polling
try:
logger.info("Бот запущен")
@@ -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()

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

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

@@ -72,13 +72,9 @@ 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:
"""Форматировать информацию о розыгрыше"""
text = f"🎲 **{lottery.title}**\n\n"

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
@@ -41,6 +41,34 @@ class ActivityService:
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(
session: AsyncSession,
@@ -84,35 +112,45 @@ class ActivityService:
Количество помеченных пользователей
"""
try:
inactive_users = await ActivityService.get_inactive_users(session, days)
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.error_type == 'inactive',
BlockedUser.is_active == True,
)
)
.exists()
)
result = await session.execute(
select(User).where(
and_(
User.last_activity < cutoff_date,
User.is_registered == True,
~already_blocked,
)
)
)
inactive_users = list(result.scalars().all())
marked_count = 0
now = datetime.now(timezone.utc)
for user in inactive_users:
# Проверяем, не помечен ли уже
stmt = select(BlockedUser).where(
and_(
BlockedUser.telegram_id == user.telegram_id,
BlockedUser.error_type == 'inactive',
BlockedUser.is_active == True
)
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
# Создаем новую запись
blocked = BlockedUser(
session.add(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),
first_blocked_at=now,
last_attempt_at=now,
attempt_count=1,
is_active=True
)
session.add(blocked)
))
marked_count += 1
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
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
await delivery_service.mark_user_blocked(
session, telegram_id, error_type, error_message
)
session.add(blocked_user)
await session.commit()
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
async def unblock_user(self, session: AsyncSession, telegram_id: int):
"""
@@ -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(
send_call = lambda: bot.send_message(
user.telegram_id,
message.text,
parse_mode="Markdown"
)
elif message.photo:
await bot.send_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(
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(
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)
send_call = lambda: 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"
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 = []

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,
)
# Создаем фабрику сессий

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)

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
# Проверяем уникальность номера
existing = await session.execute(
select(User).where(User.account_number == formatted_number)
)
if existing.scalar_one_or_none():
return False # Номер уже занят
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
return False
# Обновляем пользователя
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(account_number=formatted_number)
existing = await session.execute(
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]:
@@ -158,6 +171,22 @@ class UserService:
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,
@@ -611,38 +654,29 @@ class ParticipationService:
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})"
@@ -698,6 +731,9 @@ class ParticipationService:
except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
if results["added"]:
await session.commit()
return results
@staticmethod
@@ -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})"
@@ -776,13 +811,14 @@ 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)
@@ -805,3 +841,41 @@ class ParticipationService:
"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

@@ -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:

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']

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}")

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:

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)
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"]:
@@ -133,13 +137,16 @@ class AccountParticipationService:
results["errors"].append(result["message"])
results["details"].append(f"{result['message']}")
await session.commit()
return results
@staticmethod
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)
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 []
# Ищем совпадения
return search_accounts_by_pattern(pattern, all_accounts)[:limit]
result = await session.execute(
select(Participation.account_number)
.where(
Participation.lottery_id == lottery_id,
Participation.account_number.isnot(None),
func.replace(Participation.account_number, '-', '').contains(digits_pattern),
)
.order_by(Participation.created_at.desc())
.limit(limit)
)
return [account for account in result.scalars().all() if account]
@staticmethod
async def set_account_as_winner(

View File

@@ -1,6 +1,7 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
import asyncio
import logging
from aiogram import Router, F
from aiogram.types import (
@@ -24,6 +25,107 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
logger = logging.getLogger(__name__)
def _build_users_export_file(users_data: list[dict]) -> bytes:
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_num, user in enumerate(users_data, 2):
ws.cell(row=row_num, column=1, value=user["telegram_id"])
ws.cell(row=row_num, column=2, value=user["username"] or '')
ws.cell(row=row_num, column=3, value=user["first_name"] or '')
ws.cell(row=row_num, column=4, value=user["last_name"] or '')
ws.cell(row=row_num, column=5, value=user["nickname"] or '')
ws.cell(row=row_num, column=6, value=user["phone"] or '')
ws.cell(row=row_num, column=7, value=user["club_card_number"] or '')
ws.cell(row=row_num, column=8, value='Да' if user["is_registered"] else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user["is_admin"] else 'Нет')
ws.cell(row=row_num, column=10, value=user["verification_code"] or '')
ws.cell(row=row_num, column=11, value=user["created_at"] or '')
ws.cell(row=row_num, column=12, value=user["last_activity"] or '')
ws.cell(row=row_num, column=13, value='Да' if user["is_chat_banned"] else 'Нет')
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
excel_file = BytesIO()
wb.save(excel_file)
return excel_file.getvalue()
def _parse_users_xlsx(file_bytes: bytes) -> list[dict]:
from io import BytesIO
from openpyxl import load_workbook
wb = load_workbook(BytesIO(file_bytes), read_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
return []
headers = [h if h else '' for h in rows[0]]
telegram_id_idx = headers.index('Telegram ID')
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]:
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
user_dict[field_name] = (
value in ['Да', 'Yes', 'True', True, 1]
if field_name == 'is_registered'
else value if value else None
)
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
return users_data
async def safe_edit_message(
callback: CallbackQuery,
text: str,
@@ -179,7 +281,8 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"),
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")],
[InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")],
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report")],
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report"),
InlineKeyboardButton(text="🏦 Счета", callback_data="admin_participants_accounts_report")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -979,12 +1082,10 @@ async def list_all_participants(callback: CallbackQuery):
async with async_session_maker() as session:
users = await UserService.get_all_users(session, limit=50)
# Получаем статистику для каждого пользователя
user_stats = []
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
user_stats = [(user, stats_by_user[user.id]) for user in users]
if not user_stats:
await callback.message.edit_text(
@@ -1115,6 +1216,9 @@ async def export_participants_data(callback: CallbackQuery):
async with async_session_maker() as session:
users = await UserService.get_all_users(session)
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
export_data = {
"timestamp": datetime.now().isoformat(),
@@ -1123,7 +1227,7 @@ async def export_participants_data(callback: CallbackQuery):
}
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
stats = stats_by_user[user.id]
user_data = {
"id": user.id,
"telegram_id": user.telegram_id,
@@ -1191,12 +1295,10 @@ async def process_search_participants(message: Message, state: FSMContext):
async with async_session_maker() as session:
users = await UserService.search_users(session, search_term)
# Получаем статистику для найденных пользователей
user_stats = []
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
user_stats = [(user, stats_by_user[user.id]) for user in users]
await state.clear()
@@ -2359,16 +2461,16 @@ async def show_participants_by_lottery(callback: CallbackQuery):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data == "admin_participants_report")
async def show_participants_report(callback: CallbackQuery):
"""Отчет по участникам"""
@admin_router.callback_query(F.data == "admin_participants_accounts_report")
async def show_participants_accounts_report(callback: CallbackQuery):
"""Отчет по участникам и привязанным счетам"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
async with async_session_maker() as session:
from sqlalchemy import func, select
from ..core.models import User, Participation, Lottery
from ..core.models import User, Participation, Account
# Общая статистика по участникам
total_participants = await session.scalar(
@@ -2378,32 +2480,33 @@ async def show_participants_report(callback: CallbackQuery):
)
total_participations = await session.scalar(select(func.count(Participation.id)))
total_users = await session.scalar(select(func.count(User.id)))
# Топ активных участников
top_participants = await session.execute(
select(
User.first_name,
User.username,
User.account_number,
func.count(Participation.id).label('participations')
func.min(Account.account_number).label('account_sample'),
func.count(func.distinct(Participation.id)).label('participations')
)
.select_from(User)
.join(Participation)
.outerjoin(Account, Account.owner_id == User.id)
.group_by(User.id)
.order_by(func.count(Participation.id).desc())
.order_by(func.count(func.distinct(Participation.id)).desc())
.limit(10)
)
top_participants = top_participants.fetchall()
# Участники с аккаунтами vs без
users_with_accounts = await session.scalar(
select(func.count(User.id)).where(User.account_number.isnot(None))
select(func.count(func.distinct(Account.owner_id)))
)
users_without_accounts = await session.scalar(
select(func.count(User.id)).where(User.account_number.is_(None))
)
users_without_accounts = max((total_users or 0) - (users_with_accounts or 0), 0)
text = "📈 Отчет по участникам\n\n"
text = "📈 Отчет по участникам и счетам\n\n"
text += f"👥 Всего уникальных участников: {total_participants}\n"
text += f"📊 Всего участий: {total_participations}\n"
text += f"🏦 С номерами счетов: {users_with_accounts}\n"
@@ -2420,7 +2523,7 @@ async def show_participants_report(callback: CallbackQuery):
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_report")],
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_accounts_report")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")]
])
)
@@ -3554,20 +3657,10 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await session.commit()
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
# Отправляем уведомления победителям
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(callback.bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
# Отправляем результаты розыгрыша всем участникам (кроме победителей)
try:
await _notify_all_participants_about_results(callback.bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}")
asyncio.create_task(
_send_draw_notifications_background(callback.bot, lottery_id, winners_dict)
)
logger.info(f"Фоновая рассылка итогов запущена для розыгрыша {lottery_id}")
# Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id)
@@ -4482,6 +4575,24 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок")
async def _send_draw_notifications_background(bot, lottery_id: int, winners_dict: dict):
"""Отправить уведомления по итогам розыгрыша вне callback handler."""
async with async_session_maker() as session:
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}", exc_info=True)
try:
await _notify_all_participants_about_results(bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}", exc_info=True)
# ============================================================================
# ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ
# ============================================================================
@@ -4496,76 +4607,34 @@ async def admin_export_users(callback: CallbackQuery):
await callback.answer("⏳ Формирую файл...", show_alert=False)
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from aiogram.types import BufferedInputFile
async with async_session_maker() as session:
# Получаем всех пользователей
all_users = await UserService.get_all_users(session)
# Создаем Excel файл
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
# Заголовки
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
users_data = [
{
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"nickname": user.nickname,
"phone": user.phone,
"club_card_number": user.club_card_number,
"is_registered": user.is_registered,
"is_admin": user.is_admin,
"verification_code": user.verification_code,
"created_at": user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '',
"last_activity": user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '',
"is_chat_banned": user.is_chat_banned,
}
for user in all_users
]
# Стиль для заголовков
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# Данные пользователей
for row_num, user in enumerate(all_users, 2):
ws.cell(row=row_num, column=1, value=user.telegram_id)
ws.cell(row=row_num, column=2, value=user.username or '')
ws.cell(row=row_num, column=3, value=user.first_name or '')
ws.cell(row=row_num, column=4, value=user.last_name or '')
ws.cell(row=row_num, column=5, value=user.nickname or '')
ws.cell(row=row_num, column=6, value=user.phone or '')
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
ws.cell(row=row_num, column=10, value=user.verification_code or '')
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем в BytesIO
excel_file = BytesIO()
wb.save(excel_file)
excel_file.seek(0)
# Отправляем файл
excel_bytes = await asyncio.to_thread(_build_users_export_file, users_data)
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file = BufferedInputFile(excel_file.read(), filename=filename)
registered_count = len([u for u in all_users if u.is_registered])
file = BufferedInputFile(excel_bytes, filename=filename)
registered_count = sum(1 for user in all_users if user.is_registered)
await callback.message.answer_document(
document=file,
@@ -4631,69 +4700,21 @@ async def admin_import_users_process(message: Message, state: FSMContext):
status_msg = await message.answer("⏳ Загружаю файл...")
try:
from openpyxl import load_workbook
from io import BytesIO
# Скачиваем файл
file = await message.bot.get_file(message.document.file_id)
file_content = await message.bot.download_file(file.file_path)
# Читаем Excel файл
excel_file = BytesIO(file_content.read())
wb = load_workbook(excel_file, read_only=True)
ws = wb.active
# Читаем данные
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
# Первая строка - заголовки
headers = [h if h else '' for h in rows[0]]
# Находим индекс колонки Telegram ID
try:
telegram_id_idx = headers.index('Telegram ID')
users_data = await asyncio.to_thread(_parse_users_xlsx, file_content.read())
except ValueError:
await status_msg.edit_text("Не найдена обязательная колонка 'Telegram ID'.")
await state.clear()
return
# Создаем маппинг индексов для других полей
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]: # Пропускаем заголовки
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
if field_name == 'is_registered':
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
else:
user_dict[field_name] = value if value else None
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
if not users_data:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
await status_msg.edit_text(
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
@@ -4702,26 +4723,39 @@ async def admin_import_users_process(message: Message, state: FSMContext):
# Импортируем пользователей
async with async_session_maker() as session:
from sqlalchemy import select
added_count = 0
updated_count = 0
error_count = 0
normalized_users = []
for user_data in users_data:
try:
telegram_id = user_data.get('telegram_id')
if not telegram_id:
error_count += 1
continue
# Преобразуем telegram_id в int если это строка
try:
telegram_id = int(telegram_id)
user_data['telegram_id'] = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
# Ищем существующего пользователя
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
normalized_users.append(user_data)
existing_result = await session.execute(
select(User).where(User.telegram_id.in_([u['telegram_id'] for u in normalized_users]))
)
existing_by_tg_id = {
user.telegram_id: user
for user in existing_result.scalars().all()
}
for user_data in normalized_users:
try:
telegram_id = user_data.get('telegram_id')
existing_user = existing_by_tg_id.get(telegram_id)
if existing_user:
# Обновляем существующего
@@ -4827,7 +4861,7 @@ async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext):
buttons = [
[InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")],
[InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")],
[InlineKeyboardButton(text="<EFBFBD> Статистика рассылок", callback_data="admin_broadcast_stats")],
[InlineKeyboardButton(text="📊 Статистика рассылок", callback_data="admin_broadcast_stats")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
]

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):
"""Состояния для работы в чате"""
@@ -478,174 +482,194 @@ async def broadcast_message_with_scheduler(
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
async def _send_message_to_user(message: Message, user_telegram_id: int, retry: bool = True) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
result = await delivery_service.send_with_guard(
user_telegram_id,
lambda: message.copy_to(user_telegram_id),
retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение %s: %s",
user_telegram_id,
result.status,
)
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
async def _send_message_to_user_with_sender(
message: Message,
user_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n"
async def send_message():
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
return await message.bot.send_message(
user_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
return await message.bot.send_photo(
user_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
return await message.bot.send_video(
user_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
return await message.bot.send_document(
user_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
return await message.bot.send_animation(
user_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
return await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
return await message.bot.send_voice(
user_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
return await message.copy_to(user_telegram_id)
result = await delivery_service.send_with_guard(
user_telegram_id,
send_message,
retry=retry,
)
if result.success:
return result.telegram_message.message_id
logger.warning(
"Не удалось отправить сообщение %s: %s",
user_telegram_id,
result.status,
)
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
async def _send_message_to_admin_with_sender(
message: Message,
admin_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
async def send_message():
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
return await message.bot.send_message(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
return await message.bot.send_photo(
admin_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
return await message.bot.send_video(
admin_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
return await message.bot.send_document(
admin_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
return await message.bot.send_animation(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
return await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
return await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_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}")
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
@@ -656,7 +680,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
logger.warning(f"Не удалось переслать сообщение в канал {channel_id}: {e}")
return False, None
@@ -992,7 +1016,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
await message.answer("✅ Стикер переслан в канал")
@router.message(F.voice)
@router.message(F.voice, StateFilter(ChatStates.in_chat))
async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
await message.answer(
@@ -1002,7 +1026,7 @@ async def handle_voice_message(message: Message):
return
@router.message(F.audio)
@router.message(F.audio, StateFilter(ChatStates.in_chat))
async def handle_audio_message(message: Message):
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
await message.answer(

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,37 +417,49 @@ 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"
)
# Сохраняем в БД
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,
@@ -450,10 +468,7 @@ async def handle_p2p_message(message: Message, state: FSMContext):
text=text,
file_id=file_id,
sender_message_id=message.message_id,
recipient_message_id=sent.message_id
recipient_message_id=delivery.telegram_message.message_id
)
await message.answer("✅ Сообщение доставлено")
except Exception as e:
await message.answer(f"Не удалось доставить сообщение: {e}")

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(
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,9 +5,11 @@ 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__)
@@ -15,6 +17,8 @@ logger = logging.getLogger(__name__)
class ActivityMiddleware(BaseMiddleware):
"""Middleware для обновления last_activity при каждом взаимодействии"""
_last_updates: Dict[int, float] = {}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
@@ -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(
delivery = await delivery_service.send_with_guard(
owner.telegram_id,
lambda: bot.send_message(
owner.telegram_id,
message,
reply_markup=keyboard,
parse_mode="Markdown"
),
)
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,
lambda: bot.send_message(
user.telegram_id,
message,
reply_markup=keyboard
),
)
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

@@ -152,6 +152,7 @@ class AsyncTaskManager:
except asyncio.TimeoutError:
continue
try:
# Получаем семафоры
async with self.worker_semaphore:
user_semaphore = self._get_user_semaphore(task.user_id)
@@ -160,8 +161,8 @@ class AsyncTaskManager:
await self._execute_task(worker_name, task)
else:
await self._execute_task(worker_name, task)
# Отмечаем задачу как выполненную
finally:
# Отмечаем задачу как обработанную даже при ошибке выполнения.
self.task_queue.task_done()
except asyncio.CancelledError:

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}")