Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -25,6 +25,12 @@ MANIFEST
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.history/
|
||||||
|
CODEX.MD
|
||||||
|
AGENTS.md
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -78,6 +84,7 @@ docs/_build/
|
|||||||
# Test files
|
# Test files
|
||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
.tox/
|
.tox/
|
||||||
.nox/
|
.nox/
|
||||||
htmlcov/
|
htmlcov/
|
||||||
@@ -98,4 +105,4 @@ monitoring/
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
|||||||
144
.drone.yml
144
.drone.yml
@@ -1,113 +1,69 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: default
|
name: test-and-deploy
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
event: [push, pull_request]
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
- pull_request
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: linux
|
|
||||||
arch: amd64
|
|
||||||
|
|
||||||
clone:
|
|
||||||
depth: 1
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Шаг 1: Проверка кода
|
- name: checks
|
||||||
- name: code-quality
|
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
PYTHONPATH: /drone/src
|
BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||||
commands:
|
commands:
|
||||||
- apt-get update && apt-get install -y git
|
- apt-get update && apt-get install -y --no-install-recommends git
|
||||||
- pip install --upgrade pip
|
- pip install --upgrade pip
|
||||||
- pip install flake8 black isort mypy
|
- pip install -r requirements-dev.txt
|
||||||
- echo "🔍 Проверка стиля кода..."
|
- python -m compileall -q src main.py scripts migrations
|
||||||
- flake8 --max-line-length=120 --ignore=E203,E501,W503 src/ main.py || echo "⚠️ Предупреждения flake8"
|
- ruff check src main.py scripts tests migrations
|
||||||
- echo "🎨 Проверка форматирования..."
|
- python scripts/check_secrets.py
|
||||||
- black --check --line-length=120 src/ main.py || echo "⚠️ Форматирование может быть улучшено"
|
- pip-audit -r requirements.txt
|
||||||
- echo "📋 Проверка импортов..."
|
- pytest -q
|
||||||
- isort --check-only --profile black src/ main.py || echo "⚠️ Импорты могут быть улучшены"
|
|
||||||
|
|
||||||
# Шаг 2: Установка зависимостей
|
- name: postgres-tests
|
||||||
- name: install-dependencies
|
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
PYTHONPATH: /drone/src
|
BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||||
|
DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test"
|
||||||
|
TEST_DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test"
|
||||||
commands:
|
commands:
|
||||||
- echo "📦 Установка зависимостей..."
|
- pip install -r requirements-dev.txt
|
||||||
- pip install --upgrade pip
|
- python scripts/wait_database.py
|
||||||
- pip install -r requirements.txt
|
- alembic upgrade head
|
||||||
- echo "✅ Зависимости установлены"
|
- python scripts/check_schema.py
|
||||||
|
- pytest -q
|
||||||
|
|
||||||
# Шаг 3: Проверка импортов и синтаксиса
|
- name: package
|
||||||
- name: syntax-check
|
image: python:3.12-slim
|
||||||
|
commands:
|
||||||
|
- python scripts/build_release.py
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
PYTHONPATH: /drone/src
|
DEPLOY_HOST: "192.168.40.112"
|
||||||
DATABASE_URL: sqlite+aiosqlite:///./test.db
|
DEPLOY_USER:
|
||||||
|
from_secret: lottery_deploy_user
|
||||||
|
DEPLOY_SSH_KEY:
|
||||||
|
from_secret: lottery_deploy_ssh_key
|
||||||
|
DEPLOY_KNOWN_HOSTS:
|
||||||
|
from_secret: lottery_deploy_known_hosts
|
||||||
|
DEPLOY_PATH:
|
||||||
|
from_secret: lottery_deploy_path
|
||||||
|
LOTTERY_ENV:
|
||||||
|
from_secret: lottery_env
|
||||||
commands:
|
commands:
|
||||||
- pip install --upgrade pip
|
- apt-get update && apt-get install -y --no-install-recommends openssh-client
|
||||||
- pip install -r requirements.txt
|
- python scripts/drone_deploy.py
|
||||||
- echo "🔍 Проверка синтаксиса Python..."
|
|
||||||
- python -m py_compile main.py
|
|
||||||
- python -m py_compile src/core/*.py || echo "⚠️ Некоторые файлы не компилируются"
|
|
||||||
- python -m py_compile src/handlers/*.py || echo "⚠️ Некоторые файлы не компилируются"
|
|
||||||
- python -m py_compile src/utils/*.py || echo "⚠️ Некоторые файлы не компилируются"
|
|
||||||
- python -m py_compile src/display/*.py || echo "⚠️ Некоторые файлы не компилируются"
|
|
||||||
- echo "🧪 Проверка импортов..."
|
|
||||||
- python -c "from src.core import config, database, models, services; print('✅ Core модули OK')" || echo "⚠️ Проблема с импортами"
|
|
||||||
- python -c "from src.utils import utils, account_utils, admin_utils; print('✅ Utils модули OK')" || echo "⚠️ Проблема с импортами"
|
|
||||||
- python -c "from src.display import winner_display; print('✅ Display модули OK')" || echo "⚠️ Проблема с импортами"
|
|
||||||
- echo "✅ Проверка синтаксиса завершена"
|
|
||||||
|
|
||||||
# Шаг 4: Инициализация тестовой БД
|
|
||||||
- name: database-init
|
|
||||||
image: python:3.12-slim
|
|
||||||
environment:
|
|
||||||
PYTHONPATH: /drone/src
|
|
||||||
DATABASE_URL: sqlite+aiosqlite:///./test.db
|
|
||||||
commands:
|
|
||||||
- pip install --upgrade pip
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- echo "🗄️ Инициализация тестовой базы данных..."
|
|
||||||
- python -c "from src.core.database import init_db; import asyncio; asyncio.run(init_db())" || echo "⚠️ БД не инициализирована"
|
|
||||||
- echo "✅ Тестовая БД готова"
|
|
||||||
|
|
||||||
# Шаг 5: Запуск тестов
|
|
||||||
- name: run-tests
|
|
||||||
image: python:3.12-slim
|
|
||||||
environment:
|
|
||||||
PYTHONPATH: /drone/src
|
|
||||||
DATABASE_URL: sqlite+aiosqlite:///./test.db
|
|
||||||
BOT_TOKEN: "dummy_token_for_tests"
|
|
||||||
ADMIN_IDS: "123456789"
|
|
||||||
commands:
|
|
||||||
- pip install --upgrade pip
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- echo "🧪 Запуск тестов..."
|
|
||||||
- python test_basic_features.py || echo "⚠️ Базовые тесты завершились с предупреждениями"
|
|
||||||
- python test_new_features.py || echo "⚠️ Тесты новых функций завершились с предупреждениями"
|
|
||||||
- echo "✅ Тесты выполнены"
|
|
||||||
|
|
||||||
# Шаг 6: Создание артефактов
|
|
||||||
- name: build-artifacts
|
|
||||||
image: python:3.12-slim
|
|
||||||
commands:
|
|
||||||
- echo "📦 Создание артефактов сборки..."
|
|
||||||
- mkdir -p dist
|
|
||||||
- tar -czf dist/lottery_bot_build_${DRONE_BUILD_NUMBER}.tar.gz src/ main.py requirements.txt Makefile README.md alembic.ini migrations/
|
|
||||||
- echo "✅ Артефакты созданы"
|
|
||||||
- ls -la dist/
|
|
||||||
when:
|
when:
|
||||||
branch:
|
branch: [master]
|
||||||
- main
|
event: [push]
|
||||||
- master
|
status: [success]
|
||||||
event:
|
|
||||||
- push
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: lottery_test
|
||||||
|
POSTGRES_PASSWORD: lottery_test
|
||||||
|
POSTGRES_DB: lottery_test
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot
|
|||||||
# ID администраторов Telegram (через запятую)
|
# ID администраторов Telegram (через запятую)
|
||||||
# Узнать свой ID можно у @userinfobot
|
# Узнать свой ID можно у @userinfobot
|
||||||
ADMIN_IDS=123456789,987654321
|
ADMIN_IDS=123456789,987654321
|
||||||
|
CASHIER_IDS=
|
||||||
|
REDIS_URL=
|
||||||
|
FSM_TTL_SECONDS=86400
|
||||||
|
|
||||||
# === ЛОГИРОВАНИЕ ===
|
# === ЛОГИРОВАНИЕ ===
|
||||||
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
@@ -27,4 +30,4 @@ LOG_LEVEL=INFO
|
|||||||
# MAX_ACTIVE_LOTTERIES=10
|
# MAX_ACTIVE_LOTTERIES=10
|
||||||
|
|
||||||
# Таймаут для операций с базой данных (секунды)
|
# Таймаут для операций с базой данных (секунды)
|
||||||
# DATABASE_TIMEOUT=30
|
# DATABASE_TIMEOUT=30
|
||||||
|
|||||||
23
.env.prod
23
.env.prod
@@ -1,23 +0,0 @@
|
|||||||
# Пример конфигурации для продакшн-окружения
|
|
||||||
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
|
||||||
|
|
||||||
# Telegram Bot Token
|
|
||||||
BOT_TOKEN=6804077170:AAGw_t6ktAiwYr2mrby0PUhckt50NZaEs0E
|
|
||||||
|
|
||||||
# PostgreSQL настройки для Docker контейнера
|
|
||||||
POSTGRES_HOST=192.168.0.102
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=new_lottery_KR
|
|
||||||
POSTGRES_USER=trevor
|
|
||||||
POSTGRES_PASSWORD=Cl0ud_1985!
|
|
||||||
|
|
||||||
# Database URL для бота (использует postgres как hostname внутри Docker сети)
|
|
||||||
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/new_lottery_KR
|
|
||||||
# Redis URL
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
|
||||||
|
|
||||||
# ID администраторов (через запятую)
|
|
||||||
ADMIN_IDS=556399210,6639865742
|
|
||||||
|
|
||||||
# Настройки логирования
|
|
||||||
LOG_LEVEL=DEBUG
|
|
||||||
@@ -10,10 +10,14 @@ POSTGRES_USER=lottery_user
|
|||||||
POSTGRES_PASSWORD=your_strong_password_here
|
POSTGRES_PASSWORD=your_strong_password_here
|
||||||
|
|
||||||
# Database URL для бота (используется внутри контейнера)
|
# Database URL для бота (используется внутри контейнера)
|
||||||
DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@db:5432/lottery_bot_db
|
DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@postgres:5432/lottery_bot_db
|
||||||
|
COMPOSE_PROFILES=local-db
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
FSM_TTL_SECONDS=86400
|
||||||
|
|
||||||
# ID администраторов (через запятую)
|
# ID администраторов (через запятую)
|
||||||
ADMIN_IDS=123456789,987654321
|
ADMIN_IDS=123456789,987654321
|
||||||
|
CASHIER_IDS=
|
||||||
|
|
||||||
# Настройки логирования
|
# Настройки логирования
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,6 +5,12 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.prod
|
.env.prod
|
||||||
|
.env.*.secret
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
|
||||||
# База данных
|
# База данных
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Multi-stage build для оптимизации размера образа
|
# Multi-stage build для оптимизации размера образа
|
||||||
FROM python:3.12-slim as builder
|
FROM python:3.12-slim-bookworm AS builder
|
||||||
|
|
||||||
# Устанавливаем системные зависимости
|
# Устанавливаем системные зависимости
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -18,14 +18,19 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|||||||
pip install --no-cache-dir -r requirements.txt
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Производственный образ
|
# Производственный образ
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
# Создаем пользователя для безопасности
|
# Создаем пользователя для безопасности
|
||||||
RUN groupadd -r lottery && useradd -r -g lottery lottery
|
RUN groupadd -r lottery && useradd -r -g lottery lottery
|
||||||
|
|
||||||
# Устанавливаем необходимые системные пакеты
|
# Match the production PostgreSQL major version so backups can be restored there.
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
curl \
|
&& install -d /usr/share/postgresql-common/pgdg \
|
||||||
|
&& curl --fail --silent --show-error https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
|
||||||
|
&& echo 'deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main' \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends postgresql-client-16 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем виртуальное окружение из builder
|
# Копируем виртуальное окружение из builder
|
||||||
@@ -36,7 +41,10 @@ ENV PATH="/opt/venv/bin:$PATH"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем файлы приложения
|
# Копируем файлы приложения
|
||||||
COPY --chown=lottery:lottery . .
|
COPY --chown=lottery:lottery src/ ./src/
|
||||||
|
COPY --chown=lottery:lottery migrations/ ./migrations/
|
||||||
|
COPY --chown=lottery:lottery scripts/ ./scripts/
|
||||||
|
COPY --chown=lottery:lottery main.py alembic.ini ./
|
||||||
|
|
||||||
# Создаем необходимые директории
|
# Создаем необходимые директории
|
||||||
RUN mkdir -p /app/logs /app/data && \
|
RUN mkdir -p /app/logs /app/data && \
|
||||||
@@ -52,7 +60,7 @@ ENV PYTHONPATH="/app" \
|
|||||||
|
|
||||||
# Проверяем здоровье приложения
|
# Проверяем здоровье приложения
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD python -c "from src.core.database import async_session_maker; import asyncio; asyncio.run(async_session_maker().__aenter__())" || exit 1
|
CMD python -m src.core.health
|
||||||
|
|
||||||
# Открываем порт для мониторинга (если потребуется)
|
# Открываем порт для мониторинга (если потребуется)
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -64,4 +72,4 @@ CMD ["python", "main.py"]
|
|||||||
LABEL maintainer="Lottery Bot Team" \
|
LABEL maintainer="Lottery Bot Team" \
|
||||||
version="1.0.0" \
|
version="1.0.0" \
|
||||||
description="Telegram Bot for Lottery Management" \
|
description="Telegram Bot for Lottery Management" \
|
||||||
org.opencontainers.image.source="https://github.com/your-repo/lottery-bot"
|
org.opencontainers.image.source="https://git.smartsoltech.kr/trevor/new_lottery_bot"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Профессиональный телеграм-бот для проведения розыгрышей с расширенными возможностями управления.
|
Профессиональный телеграм-бот для проведения розыгрышей с расширенными возможностями управления.
|
||||||
|
|
||||||
|
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
|
||||||
|
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
|
||||||
|
Кассирам доступна команда `/cashier`; назначение — `/add_cashier TELEGRAM_ID` главным администратором.
|
||||||
|
|
||||||
## 🌟 Ключевые особенности
|
## 🌟 Ключевые особенности
|
||||||
|
|
||||||
- 🎲 **Создание и управление розыгрышами** - Полный жизненный цикл
|
- 🎲 **Создание и управление розыгрышами** - Полный жизненный цикл
|
||||||
@@ -15,11 +19,12 @@
|
|||||||
- 🐳 **Docker поддержка** - Легкая контейнеризация
|
- 🐳 **Docker поддержка** - Легкая контейнеризация
|
||||||
- 🚀 **CI/CD pipeline** - Автоматическое развертывание
|
- 🚀 **CI/CD pipeline** - Автоматическое развертывание
|
||||||
- 📦 **Модульная архитектура** - Простое расширение функциональности
|
- 📦 **Модульная архитектура** - Простое расширение функциональности
|
||||||
|
- ✨ **Премиум-эмодзи в чате и рассылках** - Сохранение Telegram-разметки; [условия и использование](docs/EMOJI_SYSTEM.md)
|
||||||
|
|
||||||
## 🛠 Технологический стек
|
## 🛠 Технологический стек
|
||||||
|
|
||||||
- **Python 3.12+** - Основной язык
|
- **Python 3.12+** - Основной язык
|
||||||
- **aiogram 3.16** - Telegram Bot API
|
- **aiogram 3.31** - Telegram Bot API
|
||||||
- **SQLAlchemy 2.0.36** - ORM для работы с БД
|
- **SQLAlchemy 2.0.36** - ORM для работы с БД
|
||||||
- **Alembic 1.14** - Система миграций
|
- **Alembic 1.14** - Система миграций
|
||||||
- **PostgreSQL / SQLite** - База данных
|
- **PostgreSQL / SQLite** - База данных
|
||||||
@@ -327,4 +332,4 @@ CMD ["python", "main.py"]
|
|||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
````
|
````
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
# Docker Compose для продакшн-развертывания
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# Enable COMPOSE_PROFILES=local-db only when PostgreSQL is hosted in this stack.
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
profiles: [local-db]
|
||||||
container_name: lottery_postgres
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
|
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-trevor}
|
POSTGRES_USER: ${POSTGRES_USER:-lottery_bot}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
|
# PostgreSQL itself rejects an empty password when local-db is enabled.
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"]
|
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
|
||||||
interval: 10s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 20
|
||||||
|
|
||||||
# Redis для очередей рассылки
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: lottery_redis
|
container_name: lottery_redis
|
||||||
@@ -29,51 +24,35 @@ services:
|
|||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: [CMD, redis-cli, ping]
|
||||||
interval: 10s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 20
|
||||||
|
|
||||||
# Telegram Bot
|
|
||||||
bot:
|
bot:
|
||||||
build:
|
image: ${BOT_IMAGE:-new_lottery_bot:local}
|
||||||
context: .
|
build: .
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: lottery_bot
|
container_name: lottery_bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
init: true
|
||||||
- .env.prod
|
env_file: .env.prod
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
|
||||||
- bot_data:/app/data
|
- bot_data:/app/data
|
||||||
networks:
|
|
||||||
- lottery_network
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
stop_grace_period: 45s
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
test: [CMD, python, -m, src.core.health]
|
||||||
interval: 30s
|
interval: 15s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 4
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bot_data:
|
bot_data:
|
||||||
driver: local
|
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
|
||||||
redis_data:
|
redis_data:
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
lottery_network:
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
75
docs/DRONE_DEPLOYMENT.md
Normal file
75
docs/DRONE_DEPLOYMENT.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Автодеплой через Drone
|
||||||
|
|
||||||
|
Drone: `https://drone.smartsoltech.kr`, цель: `trevor@192.168.40.112:/opt/new_lottery_bot`, репозиторий `trevor/new_lottery_bot`, production-ветка `master`.
|
||||||
|
Подготовленный pipeline запускает деплой только для успешного `push` в `master` после проверок, PostgreSQL-тестов и сборки архива. Pull request запускает проверки без передачи deployment-секретов.
|
||||||
|
|
||||||
|
## Секреты репозитория
|
||||||
|
|
||||||
|
| Имя в Drone | Значение |
|
||||||
|
| --- | --- |
|
||||||
|
| `lottery_deploy_user` | SSH-пользователь сервера с доступом к Docker и каталогу приложения |
|
||||||
|
| `lottery_deploy_path` | Отдельный абсолютный каталог, например `/opt/new_lottery_bot` |
|
||||||
|
| `lottery_deploy_ssh_key` | Закрытый SSH-ключ этого пользователя, пригодный для неинтерактивного входа |
|
||||||
|
| `lottery_deploy_known_hosts` | Проверенная запись ключа сервера для `192.168.40.112` |
|
||||||
|
| `lottery_env` | Полный актуальный production env-файл с BOT_TOKEN, DATABASE_URL, ADMIN_IDS и остальными настройками |
|
||||||
|
|
||||||
|
Настройка использует [официальный API создания](https://docs.drone.io/api/secrets/secret_create/) и [обновления секретов](https://docs.drone.io/api/secrets/secret_update/). Скрипт передаёт `pull_request=false`, повторно проверяет наличие имён и не печатает значения. Учётной записи нужны права записи в репозиторий.
|
||||||
|
|
||||||
|
На Windows, из корня проекта, после заполнения файлов за пределами репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe scripts/configure_drone_secrets.py `
|
||||||
|
--server https://drone.smartsoltech.kr `
|
||||||
|
--repo trevor/new_lottery_bot `
|
||||||
|
--token-file C:\secure\drone-token.txt `
|
||||||
|
--ssh-key C:\secure\lottery-deploy-key `
|
||||||
|
--known-hosts C:\secure\lottery-known-hosts `
|
||||||
|
--env-file C:\secure\lottery-production.env `
|
||||||
|
--deploy-user trevor `
|
||||||
|
--deploy-path /opt/new_lottery_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Пути `C:\secure\...` — примеры файлов с ограниченными правами доступа. Токены не нужно вставлять в командную строку. Отпечаток host key сверяется через доверенный доступ к серверу; автоматическое принятие неизвестного ключа отключено. Для Drone используется отдельный ключ `id_ed25519_lottery_deploy`; пользователь `trevor` имеет доступ к Docker и каталогу приложения.
|
||||||
|
|
||||||
|
При подготовке deployment актуальные настройки взяты из работающего контейнера: PostgreSQL 16 расположен на `192.168.20.2`, база `lottery_bot`. Локальный старый `.env.prod` не является источником production-настроек. Проверено совпадение эффективных BOT_TOKEN, DATABASE_URL, REDIS_URL, ADMIN_IDS и имён volumes с действующим Compose-проектом `new_lottery_bot`. Все пять секретов созданы в Drone с запретом передачи pull request.
|
||||||
|
|
||||||
|
Резервная копия рабочей БД восстановлена в изолированном PostgreSQL 16; обновление с `20260701_perf_indexes` до `20260913_staff_concurrency` и проверка схемы прошли. При обновлении PostgreSQL согласованно обновлять клиент резервного копирования: [PostgreSQL не гарантирует восстановление дампа нового клиента в более старую major-версию сервера](https://www.postgresql.org/docs/17/app-pgdump.html#APP-PGDUMP-NOTES).
|
||||||
|
|
||||||
|
## Перед первым деплоем
|
||||||
|
|
||||||
|
1. Активировать репозиторий в Drone. Runner должен получать Git-репозиторий и обращаться по SSH к `192.168.40.112`; сервер должен иметь доступ к registry/PyPI для сборки.
|
||||||
|
2. На сервере нужны Docker Engine, Compose v2 с поддержкой `--wait`, `flock`, `tar`, `install`. Пользователь должен иметь доступ к Docker и выделенному каталогу. Подготовить ключ и проверить обычный неинтерактивный SSH-вход.
|
||||||
|
3. Если уже существует контейнер `lottery_bot`, проверить его label `com.docker.compose.project`. Скрипт ожидает `new_lottery_bot` и остановится при другом проекте, чтобы не подменить чужой стек. Существующие volumes, каталог и внешний PostgreSQL необходимо сопоставить до переноса. Не запускать `down -v`.
|
||||||
|
4. В `.env.prod` использовать реальные настройки из `.env.prod.example`. Для внешней БД оставить `COMPOSE_PROFILES` пустым и указать доступный из контейнера PostgreSQL в `DATABASE_URL`; POSTGRES_PASSWORD не требуется. Для БД в этом Compose задать `COMPOSE_PROFILES=local-db`, непустой POSTGRES_PASSWORD, согласованные POSTGRES_* и адрес `postgres` в URL. Спецсимволы пароля внутри URL должны быть URL-encoded. Клиент резервного копирования и PostgreSQL в Compose/CI используют major-версию 16, соответствующую рабочей БД.
|
||||||
|
5. При использовании переменных со знаком `$` учитывать интерполяцию Compose env-файла; заключать соответствующее значение в одинарные кавычки. Проверять конфигурацию командой `docker compose --project-name new_lottery_bot --env-file .env.prod config --quiet`, чтобы значения секретов не попадали в вывод.
|
||||||
|
6. Проверить доступ к целевой БД и возможность `pg_dump`. БД должна уже существовать. Пользователю миграций нужны права изменения принадлежащих приложению объектов. Исторические дубликаты миграция сообщает без удаления данных.
|
||||||
|
7. Загрузить пять секретов указанным скриптом, опубликовать изменения в `master` и дождаться завершения **всего** pipeline, включая `deploy`.
|
||||||
|
|
||||||
|
## Последовательность deployment-скрипта
|
||||||
|
|
||||||
|
- Получает архив и env-файл по SSH с обязательной проверкой host key.
|
||||||
|
- Берёт файловую блокировку деплоя и отклоняет устаревший номер сборки.
|
||||||
|
- Создаёт каталог `releases/COMMIT-BUILD`, собирает образ с тегом commit и поднимает Redis/необходимую локальную БД.
|
||||||
|
- Делает PostgreSQL backup в `backups/COMMIT-BUILD-UTC_TIMESTAMP.dump` с ограниченными правами. Дальнейшие действия прекращаются, если backup не получен.
|
||||||
|
- Останавливает старого polling-бота, выполняет `alembic upgrade head` и проверку схемы.
|
||||||
|
- Запускает новый образ, ожидает healthcheck, проверяет heartbeat приложения и только затем переключает `current` и записывает `last-build`.
|
||||||
|
- При ошибке после остановки пытается вернуть прежний образ. **Это откат приложения:** схема БД автоматически не откатывается; используется env текущего deployment. Восстановление БД из backup требует отдельного решения. При первом запуске предыдущего образа может не быть.
|
||||||
|
|
||||||
|
Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Сбой heartbeat останавливает приложение, после чего действует Docker restart policy. Один только Docker healthcheck не является проверкой каждого бизнес-сценария или факта доставки всех сообщений.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
После успешного шага `deploy` проверить `/start`, админку, `/cashier` со второго Telegram-аккаунта, добавление участника и тестовый розыгрыш. В логах должны отсутствовать ошибки миграций, `Conflict: terminated by other getUpdates request` и повторяющиеся ошибки соединения с БД. Проверить сохранение FSM после штатного рестарта, содержимое backup и возможность восстановления на отдельной тестовой БД.
|
||||||
|
|
||||||
|
Локальные команды CI:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
|
||||||
|
.\.venv\Scripts\python.exe -m ruff check src main.py scripts tests migrations
|
||||||
|
.\.venv\Scripts\python.exe -m pytest -q
|
||||||
|
.\.venv\Scripts\python.exe -m pip_audit -r requirements.txt
|
||||||
|
.\.venv\Scripts\python.exe scripts/check_secrets.py
|
||||||
|
.\.venv\Scripts\python.exe scripts/build_release.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Для PostgreSQL-тестов задаётся `TEST_DATABASE_URL` **только отдельной одноразовой тестовой БД**: тесты удаляют и создают в ней таблицы. Пользователю тестов также требуется `CREATEDB` для проверки миграций. Рабочий `DATABASE_URL` тестами не используется: `tests/conftest.py` подставляет временную SQLite БД, если `TEST_DATABASE_URL` не указан.
|
||||||
@@ -1,244 +1,52 @@
|
|||||||
# Система управления кастомными эмодзи
|
# Премиум-эмодзи в чате и рассылках
|
||||||
|
|
||||||
## Обзор
|
Поддержка добавлена для общего чата, личных диалогов, рассылок в личные сообщения, каналы и группы. Сохраняются ID выбранных премиум-эмодзи, оформление текста и подписи к медиа. Новые миграции и переменные окружения для этой функции не требуются.
|
||||||
|
|
||||||
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
|
## Использование
|
||||||
|
|
||||||
1. Бот получает `emoji_id` от Telegram API
|
1. Выберите премиум-эмодзи в панели Telegram и отправьте сообщение в режиме чата либо создания рассылки.
|
||||||
2. Сохраняет эмодзи в таблице `emoji_mappings`
|
2. Для подписи к фото, видео или документу добавьте эмодзи прямо в подпись.
|
||||||
3. При отправке сообщений в чаты бот автоматически использует `emoji_id` вместо текста эмодзи
|
3. Бот сохраняет выбранный вариант эмодзи, включая случаи, когда у нескольких премиум-вариантов одинаковый обычный символ.
|
||||||
|
|
||||||
Это обеспечивает, что эмодзи будут выглядеть точно так же, как их отправил админ, даже если это премиум эмодзи.
|
Имя отправителя добавляется с сохранением оформления сообщения. Если заголовок не помещается вместе с текстом или подписью в лимит Telegram, бот отправляет заголовок отдельно и копирует исходное сообщение целиком.
|
||||||
|
|
||||||
## Команды администратора
|
Условия Telegram: новые сообщения с custom emoji могут отправлять боты с дополнительным именем пользователя, приобретённым через Fragment. Также они разрешены в сообщениях бота в личных чатах, группах и супергруппах, если **владелец бота** имеет активную подписку Telegram Premium. Исключение по подписке владельца в документации не распространяется на каналы. Эти условия задаёт Telegram; флаг внутри приложения их не меняет. [Официальная документация](https://core.telegram.org/bots/api#formatting-options).
|
||||||
|
|
||||||
### 1. Добавить новый эмодзи
|
Отправка обычного Unicode-символа передаёт обычный эмодзи. Премиум-вариант определяется полем `custom_emoji_id`, которое Telegram присылает вместе с выбранным эмодзи. Предварительная регистрация в каталоге для чата и рассылки не требуется.
|
||||||
|
|
||||||
```
|
## Каталог администратора
|
||||||
/add_emoji
|
|
||||||
```
|
|
||||||
|
|
||||||
Процесс:
|
- `/add_emoji` — сохранить один премиум-эмодзи и описание для шаблонов.
|
||||||
1. Админ запускает команду `/add_emoji`
|
- `/my_emojis` — показать свои записи.
|
||||||
2. Бот просит отправить эмодзи
|
- `/all_emojis` — показать общий каталог.
|
||||||
3. Админ отправляет эмодзи (например, 🎲)
|
- `/delete_emoji` — удалить свою запись. Главный администратор также может удалять записи других сотрудников.
|
||||||
4. Бот просит описание (для чего используется)
|
|
||||||
5. Админ отправляет描述 (например, "Для лотереи")
|
|
||||||
6. Бот сохраняет в БД и подтверждает
|
|
||||||
|
|
||||||
### 2. Просмотр своих эмодзи
|
Команда добавления принимает настоящий `custom_emoji` из Telegram. Описание ограничено 255 символами. Связь записи с администратором хранится через внутренний ID пользователя в БД; интерфейс принимает Telegram ID. Повторная регистрация даёт понятный ответ без повреждения сессии БД.
|
||||||
|
|
||||||
```
|
## Реализация
|
||||||
/my_emojis
|
|
||||||
```
|
|
||||||
|
|
||||||
Показывает все эмодзи, добавленные этим админом:
|
`src/utils/telegram_messages.py` содержит общий путь передачи сообщений:
|
||||||
- Сам эмодзи
|
|
||||||
- Описание
|
|
||||||
- ID (первые 30 символов)
|
|
||||||
- Дату добавления
|
|
||||||
|
|
||||||
### 3. Просмотр всех эмодзи в системе
|
- без заголовка используется `copyMessage`, сохраняющий исходное сообщение;
|
||||||
|
- при добавлении имени передаются исходные `entities` / `caption_entities` с поправкой позиции на длину заголовка в UTF-16;
|
||||||
|
- `parse_mode=None` предотвращает повторный разбор пользовательского текста;
|
||||||
|
- длинные сообщения копируются без обрезания текста или разрыва эмодзи.
|
||||||
|
|
||||||
```
|
Общий чат, P2P и оба вида рассылок используют этот путь. Ошибка оформления сообщения, включая недопустимый custom emoji, учитывается как ошибка доставки и не блокирует получателя для следующих рассылок.
|
||||||
/all_emojis
|
|
||||||
```
|
|
||||||
|
|
||||||
Показывает все эмодзи всех админов с информацией об администраторе
|
Для программно создаваемого шаблона из обычного текста доступен HTML-рендер каталога:
|
||||||
|
|
||||||
### 4. Удалить эмодзи
|
|
||||||
|
|
||||||
```
|
|
||||||
/delete_emoji
|
|
||||||
```
|
|
||||||
|
|
||||||
Админ может удалить только свои эмодзи. Процесс:
|
|
||||||
1. Вызвать команду
|
|
||||||
2. Выбрать эмодзи из список (кнопки)
|
|
||||||
3. Бот удалит из БД
|
|
||||||
|
|
||||||
## Использование в коде
|
|
||||||
|
|
||||||
### Простой способ - прямое использование эмодзи
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from aiogram.types import Message
|
|
||||||
|
|
||||||
async def handler(message: Message):
|
|
||||||
await message.answer(
|
|
||||||
text="🎲 Добро пожаловать на лотерею! 🏆",
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### С обработкой эмодзи
|
|
||||||
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from src.core.emoji_message_helper import get_emoji_aware_text
|
from src.core.emoji_message_helper import get_emoji_aware_text
|
||||||
from aiogram.types import Message
|
|
||||||
|
|
||||||
async def handler(message: Message, session: AsyncSession):
|
html = await get_emoji_aware_text(session, "🎲 Розыгрыш начался!")
|
||||||
# Текст с эмодзи
|
await bot.send_message(chat_id, html, parse_mode="HTML")
|
||||||
original_text = "🎲 Выиграли! 🏆"
|
|
||||||
|
|
||||||
# Обработаны текст (эмодзи заменены на ID для корректного отображения)
|
|
||||||
processed_text = await get_emoji_aware_text(session, original_text)
|
|
||||||
|
|
||||||
await message.answer(processed_text, parse_mode="HTML")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Работа с EmojiMessageHelper
|
Вход этого помощника — обычный текст. Он экранирует HTML и добавляет настоящие `<tg-emoji emoji-id="...">...</tg-emoji>`. Входящие пользовательские сообщения сохраняют собственные entities и не проходят через каталог замен.
|
||||||
|
|
||||||
```python
|
## Проверка
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from src.core.emoji_message_helper import EmojiMessageHelper
|
|
||||||
|
|
||||||
async def handler(message: Message, session: AsyncSession):
|
`tests/test_premium_emoji.py` проверяет ID и UTF-16-позиции нескольких эмодзи, вложенное оформление, подписи к шести типам медиа, предельную длину текста/подписей, P2P-доставку, копирование рассылок, ошибки Telegram и настоящий маршрут регистрации эмодзи. Ответы Telegram в тестах подменены; права конкретного рабочего бота и отображение в клиенте Telegram нужно проверить после установки релиза.
|
||||||
helper = EmojiMessageHelper(session)
|
|
||||||
|
|
||||||
# Обработка перед отправкой
|
|
||||||
text = "🎲 Лотерея начинается! 💎"
|
|
||||||
processed = await helper.process_text_before_send(text)
|
|
||||||
|
|
||||||
await message.answer(processed, parse_mode="HTML")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура БД
|
Итоговый прогон 13 сентября 2026: PostgreSQL — 61 тест прошёл; SQLite — 60 прошли, один тест миграций пропущен. Ruff, compileall и проверка отслеживаемых секретов прошли. Изменения подготовлены локально; установки этой версии на рабочий сервер в рамках проверки не было.
|
||||||
|
|
||||||
### Таблица `emoji_mappings`
|
|
||||||
|
|
||||||
| Колонка | Тип | Описание |
|
|
||||||
|---------|-----|---------|
|
|
||||||
| `id` | Integer | Primary Key |
|
|
||||||
| `emoji_text` | String(10) | Сам эмодзи (например, 🎲) |
|
|
||||||
| `emoji_id` | String(255) | telegram_emoji_id от API (уникален) |
|
|
||||||
| `admin_id` | Integer | FK на user (администратор) |
|
|
||||||
| `description` | String(255) | Описание назначения эмодзи |
|
|
||||||
| `created_at` | DateTime | Дата добавления |
|
|
||||||
| `last_used_at` | DateTime | Последнее использование |
|
|
||||||
|
|
||||||
### Уникальные ограничения
|
|
||||||
|
|
||||||
- `emoji_id` — уникален во всей системе
|
|
||||||
- `(emoji_text, admin_id)` — один админ не может добавить один эмодзи дважды
|
|
||||||
|
|
||||||
## API сервиса EmojiMappingService
|
|
||||||
|
|
||||||
### Регистрация эмодзи
|
|
||||||
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from src.core.emoji_mapping_service import EmojiMappingService
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
service = EmojiMappingService(session)
|
|
||||||
|
|
||||||
emoji = await service.register_emoji(
|
|
||||||
emoji_text="🎲",
|
|
||||||
emoji_id="telegram_emoji_id_here",
|
|
||||||
admin_id=12345,
|
|
||||||
description="Для лотереи"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получение эмодзи
|
|
||||||
|
|
||||||
```python
|
|
||||||
# По тексту
|
|
||||||
emoji = await service.get_emoji_by_text("🎲")
|
|
||||||
|
|
||||||
# По emoji_id
|
|
||||||
emoji = await service.get_emoji_by_id("telegram_emoji_id")
|
|
||||||
|
|
||||||
# Все эмодзи админа
|
|
||||||
emojis = await service.get_all_emoji_by_admin(admin_id=12345)
|
|
||||||
|
|
||||||
# Все эмодзи
|
|
||||||
all_emojis = await service.get_all_emojis()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Замена эмодзи в тексте
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Текст → с заменой эмодзи на ID
|
|
||||||
processed = await service.replace_emojis_in_text(
|
|
||||||
"🎲 Выиграли! 🏆"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обратно - ID → эмодзи
|
|
||||||
original = await service.restore_emojis_in_text(processed)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получить словарь маппинга
|
|
||||||
|
|
||||||
```python
|
|
||||||
# {emoji_text: emoji_id}
|
|
||||||
mapping = await service.get_emoji_mapping_dict()
|
|
||||||
# {'🎲': 'telegram_emoji_id_1', '🏆': 'telegram_emoji_id_2', ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Примеры использования в разных рутерах
|
|
||||||
|
|
||||||
### В регистрации
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def registration_complete(message: Message, session: AsyncSession):
|
|
||||||
text = "✅ Регистрация завершена! 🎉"
|
|
||||||
text = await get_emoji_aware_text(session, text)
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
|
||||||
```
|
|
||||||
|
|
||||||
### В админ-панели
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def lottery_created(callback: CallbackQuery, session: AsyncSession):
|
|
||||||
text = "🎰 Новый розыгрыш создан! 🏆"
|
|
||||||
text = await get_emoji_aware_text(session, text)
|
|
||||||
await callback.message.edit_text(text, parse_mode="HTML")
|
|
||||||
```
|
|
||||||
|
|
||||||
### В чатовой рассылке
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def broadcast_message(message: Message, session: AsyncSession):
|
|
||||||
text = f"📢 Сообщение от админа: {message.text}\n\n💎 Удачи!"
|
|
||||||
text = await get_emoji_aware_text(session, text)
|
|
||||||
|
|
||||||
for user_id in target_users:
|
|
||||||
await bot.send_message(user_id, text, parse_mode="HTML")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Важные моменты
|
|
||||||
|
|
||||||
1. **Parse Mode**: Всегда используйте `parse_mode="HTML"` при работе с эмодзи
|
|
||||||
2. **Кеширование ID**: Система не кеширует, каждый раз обращается к БД. Для оптимизации можно добавить кеширование
|
|
||||||
3. **Лог использования**: `last_used_at` обновляется автоматически при замене в тексте
|
|
||||||
4. **Удаление**: Удаленный эмодзи больше не будет заменяться в новых сообщениях
|
|
||||||
5. **Конфликты**: Если два админа добавляют один эмодзи - они сохранятся отдельно (разные admin_id)
|
|
||||||
|
|
||||||
## Миграция
|
|
||||||
|
|
||||||
Таблица создана миграцией:
|
|
||||||
```
|
|
||||||
migrations/versions/20260307_0100_add_emoji_mappings.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Применить миграцию:
|
|
||||||
```bash
|
|
||||||
alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
## Trouble Shooting
|
|
||||||
|
|
||||||
### Эмодзи не отображается корректно
|
|
||||||
- Проверьте что используете `parse_mode="HTML"`
|
|
||||||
- Убедитесь что эмодзи зарегистрирован с помощью `/my_emojis`
|
|
||||||
|
|
||||||
### Ошибка "Can't parse entities"
|
|
||||||
- Это означает что есть конфликт форматирования
|
|
||||||
- Убедитесь что используете HTML теги (`<b>`, `<i>`, и т.д.), а не Markdown (`**`, `__`)
|
|
||||||
|
|
||||||
### Эмодзи не заменяется
|
|
||||||
- Проверьте что был зарегистрирован с помощью `/add_emoji`
|
|
||||||
- Убедитесь что используете функцию `get_emoji_aware_text()` перед отправкой
|
|
||||||
|
|||||||
67
docs/STABILIZATION_REPORT.md
Normal file
67
docs/STABILIZATION_REPORT.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Проверка и стабилизация бота призовых розыгрышей
|
||||||
|
|
||||||
|
Дата: 13 сентября 2026. Исходная ревизия: `733298b`, ветка `master`.
|
||||||
|
Отчёт о проверках перед выпуском. Результат конкретного deployment проверяется по соответствующему pipeline в Drone и состоянию контейнера на сервере.
|
||||||
|
|
||||||
|
## Устройство проекта
|
||||||
|
|
||||||
|
| Область | Основные файлы | Назначение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Запуск | `main.py`, `src/core/config.py`, `src/core/database.py` | aiogram polling, FSM, соединения с БД, фоновые задачи |
|
||||||
|
| Пользовательские сценарии | `src/controllers/`, `src/components/`, `src/handlers/registration_handlers.py` | Меню, регистрация, просмотр розыгрышей и счетов |
|
||||||
|
| Управление | `src/handlers/admin_panel.py`, `admin_account_handlers.py`, `cashier_handlers.py` | Администраторы, кассиры, участники, счета, результаты |
|
||||||
|
| Данные и операции | `src/core/models.py`, `services.py`, `registration_services.py`, `src/handlers/account_services.py` | PostgreSQL, участие, проведение розыгрыша, подтверждение выигрыша |
|
||||||
|
| Общение | `src/core/chat_services.py`, `broadcast_services.py`, `src/handlers/chat_handlers.py`, `p2p_chat.py` | Чат, личные сообщения, рассылки и модерация |
|
||||||
|
| Развёртывание | `.drone.yml`, `Dockerfile`, `docker-compose.yml`, `scripts/deploy_release.sh` | CI, артефакт, резервная копия, миграции и запуск |
|
||||||
|
|
||||||
|
## Исправленные проблемы
|
||||||
|
|
||||||
|
| Приоритет | Проблема | Исправление |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Критический | В Git отслеживался `.env.prod`; пароль БД также находился в Compose | Локальный файл сохранён, его удаление из индекса подготовлено; Compose читает настройки окружения, образ и релиз собираются по разрешённому списку файлов. Старые секреты остаются в истории и требуют замены |
|
||||||
|
| Высокий | Проверки администратора различались; отдельные callback/FSM шаги позволяли обойти проверку меню | Общий источник ролей и middleware проверяют выбранный обработчик; пользовательские callback выделены явно; права назначенного администратора учитываются в админке |
|
||||||
|
| Высокий | Два администратора могли одновременно провести один розыгрыш, два кассира — подтвердить один приз | Транзакционная блокировка конкретного розыгрыша, уникальные ограничения, условное обновление неподтверждённого выигрыша |
|
||||||
|
| Высокий | Повторное добавление участника/счёта и одновременное создание пользователя приводили к дублям или IntegrityError | Атомарная вставка/обновление пользователя, блокировки изменений участия, ограничения уникальности, восстановление сессии после ожидаемых конфликтов |
|
||||||
|
| Высокий | Победителя по Telegram ID мог подтвердить другой пользователь | Проверяется владелец выигрыша; подтверждение до завершения розыгрыша запрещено |
|
||||||
|
| Высокий | Повторный розыгрыш мог затронуть подтверждённые призы и конфликтовать с подтверждением кассира | Общий сервис повторного розыгрыша блокирует результаты, заменяет только подходящие неподтверждённые места и сохраняет остальные призы |
|
||||||
|
| Высокий | Глобальная проверка повторов сообщений путала одинаковые номера из разных чатов; модерация искала сообщение без чата | Ключ `(chat_id, message_id)`; поиск оригиналов и пересланных копий учитывает чат получателя и отклоняет неоднозначный результат |
|
||||||
|
| Высокий | Синхронный Excel и удержание соединений БД во время массовых отправок мешали другим операциям | Работа с Excel вынесена в поток; данные подготавливаются до отправки; основные рассылки освобождают соединение БД, имеют общий лимит параллельных запросов и темпа |
|
||||||
|
| Высокий | Первоначальная цепочка Alembic имела две вершины и повторно создавала существовавшие таблицы; рабочая ревизия отсутствовала в Git | Включена фактическая ревизия `20260701_perf_indexes` с сервера и объединяющая миграция; дублирующая историческая ревизия оставлена как пустой переход. Проверяется полная цепочка и обновление с рабочей ревизии |
|
||||||
|
| Средний | Возврат через кнопку использовал автора сообщения — самого бота | Для действий пользователя передаётся `callback.from_user`; выход очищает его FSM |
|
||||||
|
| Средний | Excel мог содержать формулы, чрезмерно большой архив или строку, срывающую весь импорт | Экспорт строк как текста; ограничения размера/числа строк; безопасный XML; отдельная транзакция для каждой строки; импорт не назначает административные роли |
|
||||||
|
| Средний | HTML в имени/тексте ломал пересылку, длинный текст переставал помещаться с заголовком | Экранирование и сохранение Telegram entities; для предельного размера заголовок отправляется отдельно |
|
||||||
|
| Средний | Разные интерфейсы бана/разбана расходились; повторная запись о блокировке ломала рассылку и задачу неактивности | Оба механизма блокировки согласованы; служебные записи обновляются атомарно |
|
||||||
|
| Средний | Ошибки SQL/исключений уходили пользователю; были обращения к удалённому `User.account_number` и несуществующим методам | Общие сообщения об ошибке без технических деталей, исправленные обращения к Account и сервисам, критическая статическая проверка в CI |
|
||||||
|
| Средний | CI скрывал ошибки, а наличие процесса считалось успешным деплоем | Проверки останавливают pipeline при ошибке; проверяются миграции, схема, доступ к БД, запуск Telegram и heartbeat приложения |
|
||||||
|
| Средний | Установленные зависимости содержали известные уязвимости; старый aioredis несовместим с Python 3.12 | Обновлены aiogram/aiohttp/python-dotenv, удалён неиспользуемый aioredis; выполнен pip-audit |
|
||||||
|
|
||||||
|
## Одновременная работа сотрудников
|
||||||
|
|
||||||
|
Состояние диалога разделено по боту, чату и пользователю. В Compose используется Redis с сохранением данных; локальный запуск без `REDIS_URL` использует память. Блокировка FSM упорядочивает события одного пользователя, не закрывая доступ другим пользователям. Изменения одного розыгрыша сериализуются на уровне PostgreSQL; разные розыгрыши могут обрабатываться одновременно.
|
||||||
|
|
||||||
|
`ADMIN_IDS` задаёт главных администраторов. Назначенные в БД администраторы используют админку. `CASHIER_IDS` либо `User.is_cashier` дают доступ к кассе: счета, добавление участников по счетам и подтверждение выигрышей. Создание/проведение розыгрышей и управление сотрудниками остаются административными операциями.
|
||||||
|
|
||||||
|
Главный администратор может выполнить `/add_cashier TELEGRAM_ID`, `/remove_cashier TELEGRAM_ID`, `/cashiers`. Сначала сотрудник должен открыть бота через `/start`. Кассир открывает `/cashier`; `/cancel` отменяет текущий ввод. Для кассира из `CASHIER_IDS` отзыв выполняется через настройку окружения.
|
||||||
|
|
||||||
|
Поддерживается один процесс Telegram polling с несколькими одновременно работающими сотрудниками. Запуск нескольких polling-контейнеров с одним токеном не является способом масштабирования этой конфигурации.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Добавлены проверки конкурентного создания пользователей и участия, нескольких победных счетов одного пользователя, повторного проведения/подтверждения/перерозыгрыша, прав и их отзыва, настоящих маршрутов aiogram с подменённым Telegram API, независимости диалогов, импорта/экспорта, пересылок и модерации.
|
||||||
|
|
||||||
|
CI запускает тесты на SQLite и отдельной PostgreSQL 16, соответствующей рабочему серверу. Проверка Alembic создаёт дополнительную временную БД, выполняет полную миграцию и обновление с рабочей ревизии, повторяет upgrade head и проверяет наличие ожидаемых таблиц/полей. SQLite используется для тестов сервисов через SQLAlchemy metadata; историческая цепочка миграций предназначена для PostgreSQL.
|
||||||
|
|
||||||
|
Итоговый локальный прогон с поддержкой премиум-эмодзи: **62 passed на PostgreSQL 15**; **60 passed, 2 skipped на SQLite** (пропущены PostgreSQL-тесты миграций). Оба прогона выдают по шесть предупреждений openpyxl об устаревающем `datetime.utcnow`; падений тестов нет. Поддержка эмодзи описана в [EMOJI_SYSTEM.md](EMOJI_SYSTEM.md).
|
||||||
|
|
||||||
|
Дополнительно прошли compileall, Ruff для критических ошибок, проверка состава релизного архива, поиск секретов в отслеживаемых файлах и синтаксическая проверка shell-скрипта. `pip-audit -r requirements.txt` сообщил `No known vulnerabilities found`. Это результат проверки известных уязвимостей зависимостей на дату аудита, а не гарантия безопасности всего приложения.
|
||||||
|
|
||||||
|
## Что ещё нельзя считать проверенным
|
||||||
|
|
||||||
|
1. **Drone и сервер.** Проверены SSH-доступ, права Docker, активность репозитория `trevor/new_lottery_bot` в `https://drone.smartsoltech.kr`. Настроены пять deployment-секретов. Успех удалённого deployment подтверждается отдельно после push; локальный зелёный прогон его не заменяет.
|
||||||
|
2. **Рабочая инфраструктура.** Docker-образ собран на целевом хосте. Эффективные настройки и volumes сопоставлены с действующим контейнером; PostgreSQL 16.15 на `192.168.20.2` доступен. Реальная проверка восстановления выявила несовместимость клиента pg_dump 17 с версией рабочей БД 16; образ закрепляет клиент 16 из официального PostgreSQL APT-репозитория. После исправления production backup успешно восстановлен в изолированном PostgreSQL 16, применена новая миграция и проверена схема; рабочая БД этой проверкой не изменялась. Автоматический откат приложения при искусственном сбое пока не проверялся.
|
||||||
|
3. **История секретов.** Удаление файла из будущего коммита не отзывает ранее записанные токен бота и пароль БД. Их нужно сменить и передать новые значения в Drone. История Git не переписывалась.
|
||||||
|
4. **Подтверждение клубной карты.** Текущая бизнес-логика принимает введённый пользователем номер карты без проверки по внешней системе или отдельного подтверждения кассиром. Она не доказывает, что карта принадлежит заявителю. Для устранения этого риска требуется определить источник проверки владельца; автоматического подтверждения такой принадлежности сейчас нет.
|
||||||
|
5. **Нагрузка и внешние сбои.** Тесты используют подменённые ответы Telegram и отдельные тестовые БД. Длительная нагрузка с реальными пользователями и потеря сети/Redis ещё не проверены. Рассылки не имеют долговременной очереди восстановления после рестарта; незавершённую рассылку может потребоваться повторить.
|
||||||
|
6. **Производственные данные.** Проверка перед выпуском не обнаружила дубликатов участников или призовых мест. Новая миграция повторяет эту проверку и останавливается при неоднозначных данных; она не удаляет их автоматически.
|
||||||
|
|
||||||
|
Инструкция по секретам и первому запуску: [DRONE_DEPLOYMENT.md](DRONE_DEPLOYMENT.md). Проверка выявленных дефектов не является гарантией отсутствия всех возможных ошибок.
|
||||||
138
main.py
138
main.py
@@ -9,12 +9,16 @@ from contextlib import asynccontextmanager
|
|||||||
from aiogram import Bot, Dispatcher, Router, F
|
from aiogram import Bot, Dispatcher, Router, F
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
||||||
|
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
||||||
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
|
|
||||||
from src.core.config import BOT_TOKEN
|
from src.core.config import BOT_TOKEN, REDIS_URL, FSM_TTL_SECONDS, LOG_LEVEL
|
||||||
|
from src.core.access import is_admin, is_staff
|
||||||
|
from src.middlewares.access import AccessMiddleware
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.scheduler import bot_scheduler
|
from src.core.scheduler import bot_scheduler
|
||||||
from src.container import container
|
from src.container import container
|
||||||
@@ -34,18 +38,32 @@ from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
|||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=LOG_LEVEL,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Создание бота и диспетчера
|
# Создание бота и диспетчера
|
||||||
bot = Bot(token=BOT_TOKEN)
|
bot = Bot(token=BOT_TOKEN, session=AiohttpSession(timeout=30, limit=100))
|
||||||
storage = MemoryStorage()
|
if REDIS_URL:
|
||||||
dp = Dispatcher(storage=storage)
|
storage = RedisStorage.from_url(
|
||||||
|
REDIS_URL, key_builder=DefaultKeyBuilder(with_bot_id=True),
|
||||||
|
state_ttl=FSM_TTL_SECONDS, data_ttl=FSM_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
isolation = storage.create_isolation(lock_kwargs={"timeout": 3600})
|
||||||
|
else:
|
||||||
|
storage = MemoryStorage()
|
||||||
|
isolation = SimpleEventIsolation()
|
||||||
|
dp = Dispatcher(storage=storage, events_isolation=isolation)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CaseInsensitiveCommand("cancel"))
|
||||||
|
async def cancel_dialog(message: Message, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("Операция отменена. /start — главное меню, /cashier — касса.")
|
||||||
|
|
||||||
|
|
||||||
# Middleware для логирования всех callback'ов
|
# Middleware для логирования всех callback'ов
|
||||||
@dp.callback_query.middleware()
|
@dp.callback_query.middleware()
|
||||||
async def log_callback_middleware(handler, event, data):
|
async def log_callback_middleware(handler, event, data):
|
||||||
@@ -68,8 +86,9 @@ async def get_controller():
|
|||||||
# === COMMAND HANDLERS ===
|
# === COMMAND HANDLERS ===
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("start"))
|
@router.message(CaseInsensitiveCommand("start"))
|
||||||
async def cmd_start(message: Message):
|
async def cmd_start(message: Message, state: FSMContext):
|
||||||
"""Обработчик команды /start (регистронезависимо)"""
|
"""Обработчик команды /start (регистронезависимо)"""
|
||||||
|
await state.clear()
|
||||||
async with get_controller() as controller:
|
async with get_controller() as controller:
|
||||||
await controller.handle_start(message)
|
await controller.handle_start(message)
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ async def btn_lotteries(message: Message):
|
|||||||
"""Обработчик кнопки 'Розыгрыши'"""
|
"""Обработчик кнопки 'Розыгрыши'"""
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.repositories.implementations import LotteryRepository, ParticipationRepository
|
from src.repositories.implementations import LotteryRepository, ParticipationRepository
|
||||||
from src.display.message_formatter import MessageFormatterImpl
|
from src.components.ui import MessageFormatterImpl
|
||||||
from src.components.ui import KeyboardBuilderImpl
|
from src.components.ui import KeyboardBuilderImpl
|
||||||
from src.core.services import UserService
|
from src.core.services import UserService
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
@@ -101,11 +120,11 @@ async def btn_lotteries(message: Message):
|
|||||||
for lottery in lotteries:
|
for lottery in lotteries:
|
||||||
participants_count = await participation_repo.get_count_by_lottery(lottery.id)
|
participants_count = await participation_repo.get_count_by_lottery(lottery.id)
|
||||||
lottery_info = formatter.format_lottery_info(lottery, participants_count)
|
lottery_info = formatter.format_lottery_info(lottery, participants_count)
|
||||||
text += lottery_info + "\n" + "="*30 + "\n\n"
|
await message.answer(lottery_info, parse_mode="HTML")
|
||||||
|
|
||||||
# Получаем информацию о регистрации пользователя
|
# Получаем информацию о регистрации пользователя
|
||||||
user_service = UserService(session)
|
user = await UserService.get_or_create_user(
|
||||||
user = await user_service.get_or_create_user(
|
session,
|
||||||
telegram_id=message.from_user.id,
|
telegram_id=message.from_user.id,
|
||||||
username=message.from_user.username,
|
username=message.from_user.username,
|
||||||
first_name=message.from_user.first_name,
|
first_name=message.from_user.first_name,
|
||||||
@@ -114,7 +133,7 @@ async def btn_lotteries(message: Message):
|
|||||||
|
|
||||||
keyboard_builder = KeyboardBuilderImpl()
|
keyboard_builder = KeyboardBuilderImpl()
|
||||||
keyboard = keyboard_builder.get_main_keyboard(
|
keyboard = keyboard_builder.get_main_keyboard(
|
||||||
is_admin=message.from_user.id in ADMIN_IDS,
|
is_admin=await is_admin(message.from_user.id),
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,30 +153,9 @@ async def btn_chat(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
@router.message(F.text == "📝 Регистрация")
|
@router.message(F.text == "📝 Регистрация")
|
||||||
async def btn_registration(message: Message, state: FSMContext):
|
async def btn_registration(message: Message, state: FSMContext):
|
||||||
"""Обработчик кнопки 'Регистрация'"""
|
from src.handlers.registration_handlers import begin_registration
|
||||||
from src.handlers.registration_handlers import RegistrationStates
|
await begin_registration(message, state)
|
||||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|
||||||
|
|
||||||
logger.info(f"User {message.from_user.id} pressed Registration button")
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"📝 Регистрация в системе\n\n"
|
|
||||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
|
||||||
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
|
||||||
"🎭 Введите ваш никнейм для чата:\n"
|
|
||||||
"• От 2 до 20 символов\n"
|
|
||||||
"• Может содержать буквы, цифры, пробелы\n"
|
|
||||||
"• Это имя будут видеть другие участники"
|
|
||||||
)
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
text,
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("register"))
|
@router.message(CaseInsensitiveCommand("register"))
|
||||||
@@ -179,7 +177,7 @@ async def btn_my_code(message: Message):
|
|||||||
await show_verification_code(message)
|
await show_verification_code(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "<EFBFBD> Мои логины")
|
@router.message(F.text.in_({"📱 Мои логины", "💳 Мои логины", "<EFBFBD> Мои логины"}))
|
||||||
async def btn_my_accounts(message: Message):
|
async def btn_my_accounts(message: Message):
|
||||||
"""Обработчик кнопки 'Мои логины'"""
|
"""Обработчик кнопки 'Мои логины'"""
|
||||||
from src.handlers.registration_handlers import show_user_accounts
|
from src.handlers.registration_handlers import show_user_accounts
|
||||||
@@ -207,9 +205,9 @@ async def btn_exit_chat(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "🏠 Главная")
|
@router.message(F.text == "🏠 Главная")
|
||||||
async def btn_main_menu(message: Message):
|
async def btn_main_menu(message: Message, state: FSMContext):
|
||||||
"""Обработчик кнопки 'Главная'"""
|
"""Обработчик кнопки 'Главная'"""
|
||||||
await cmd_start(message)
|
await cmd_start(message, state)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("admin"))
|
@router.message(CaseInsensitiveCommand("admin"))
|
||||||
@@ -260,10 +258,12 @@ async def active_lotteries_handler(callback: CallbackQuery):
|
|||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "back_to_main")
|
@router.callback_query(F.data == "back_to_main")
|
||||||
async def back_to_main_handler(callback: CallbackQuery):
|
async def back_to_main_handler(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Обработчик возврата в главное меню"""
|
"""Обработчик возврата в главное меню"""
|
||||||
|
await state.clear()
|
||||||
|
await callback.answer()
|
||||||
async with get_controller() as controller:
|
async with get_controller() as controller:
|
||||||
await controller.handle_start(callback.message)
|
await controller.handle_start(callback.message, actor=callback.from_user)
|
||||||
# Функции обрабатываются в:
|
# Функции обрабатываются в:
|
||||||
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
||||||
# - registration_handlers.py: регистрация пользователей
|
# - registration_handlers.py: регистрация пользователей
|
||||||
@@ -276,17 +276,38 @@ async def back_to_main_handler(callback: CallbackQuery):
|
|||||||
# Обработка неизвестных callback и сообщений происходит в соответствующих роутерах
|
# Обработка неизвестных callback и сообщений происходит в соответствующих роутерах
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
def configure_dispatcher():
|
||||||
"""Главная функция запуска бота"""
|
"""Главная функция запуска бота"""
|
||||||
logger.info("Запуск бота...")
|
logger.info("Запуск бота...")
|
||||||
|
from src.middlewares.runtime import PrivateDialogMiddleware, ErrorMiddleware
|
||||||
|
for observer in (dp.message, dp.callback_query):
|
||||||
|
observer.outer_middleware(PrivateDialogMiddleware())
|
||||||
|
observer.outer_middleware(ErrorMiddleware())
|
||||||
|
|
||||||
# Подключаем middleware для отслеживания активности
|
# Подключаем middleware для отслеживания активности
|
||||||
dp.message.middleware(ActivityMiddleware())
|
dp.message.middleware(ActivityMiddleware())
|
||||||
dp.callback_query.middleware(ActivityMiddleware())
|
dp.callback_query.middleware(ActivityMiddleware())
|
||||||
|
|
||||||
|
# Enforce access on every selected handler, including stale FSM callbacks.
|
||||||
|
from src.middlewares.access import ChatAccessMiddleware
|
||||||
|
p2p_chat_router.message.middleware(ChatAccessMiddleware())
|
||||||
|
p2p_chat_router.callback_query.middleware(ChatAccessMiddleware())
|
||||||
|
for protected in (admin_router, admin_chat_router, redraw_router, message_admin_router, admin_emoji_router):
|
||||||
|
protected.message.middleware(AccessMiddleware())
|
||||||
|
protected.callback_query.middleware(AccessMiddleware(
|
||||||
|
public_handlers={"confirm_winner_callback"} if protected is redraw_router else (),
|
||||||
|
))
|
||||||
|
for protected in (admin_account_router,):
|
||||||
|
protected.message.middleware(AccessMiddleware(allow_cashier=True))
|
||||||
|
protected.callback_query.middleware(AccessMiddleware(
|
||||||
|
allow_cashier=True, public_handlers={"view_my_accounts_callback"},
|
||||||
|
))
|
||||||
|
|
||||||
# Подключаем роутеры в правильном порядке
|
# Подключаем роутеры в правильном порядке
|
||||||
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
|
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
|
||||||
dp.include_router(router)
|
dp.include_router(router)
|
||||||
|
from src.handlers.cashier_handlers import cashier_router
|
||||||
|
dp.include_router(cashier_router)
|
||||||
|
|
||||||
# 2. Специфичные роутеры
|
# 2. Специфичные роутеры
|
||||||
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
||||||
@@ -305,20 +326,46 @@ async def main():
|
|||||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
configure_dispatcher()
|
||||||
# Запускаем планировщик задач
|
# Запускаем планировщик задач
|
||||||
bot_scheduler.start()
|
bot_scheduler.start()
|
||||||
logger.info("Планировщик задач запущен")
|
logger.info("Планировщик задач запущен")
|
||||||
|
|
||||||
# Запускаем polling
|
# Запускаем polling
|
||||||
try:
|
try:
|
||||||
|
from src.core.health import HEARTBEAT, heartbeat
|
||||||
|
HEARTBEAT.unlink(missing_ok=True)
|
||||||
|
if REDIS_URL:
|
||||||
|
await storage.redis.ping()
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.core.models import User
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await session.execute(select(User).limit(0))
|
||||||
|
await bot.get_me()
|
||||||
|
health_task = asyncio.create_task(heartbeat())
|
||||||
logger.info("Бот запущен")
|
logger.info("Бот запущен")
|
||||||
await dp.start_polling(bot)
|
polling_task = asyncio.create_task(dp.start_polling(bot))
|
||||||
except Exception as e:
|
completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||||
logger.error(f"Ошибка при запуске бота: {e}")
|
for task in completed:
|
||||||
|
await task
|
||||||
finally:
|
finally:
|
||||||
|
from src.core.database import close_db
|
||||||
|
from contextlib import suppress
|
||||||
|
for task_name in ("polling_task", "health_task"):
|
||||||
|
task = locals().get(task_name)
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
|
await task
|
||||||
|
HEARTBEAT.unlink(missing_ok=True)
|
||||||
# Останавливаем планировщик
|
# Останавливаем планировщик
|
||||||
bot_scheduler.shutdown()
|
bot_scheduler.shutdown()
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
|
await storage.close()
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -327,4 +374,5 @@ if __name__ == "__main__":
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Бот остановлен пользователем")
|
logger.info("Бот остановлен пользователем")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Критическая ошибка: {e}")
|
logger.exception("Критическая ошибка")
|
||||||
|
raise
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ target_metadata = Base.metadata
|
|||||||
from src.core.database import DATABASE_URL
|
from src.core.database import DATABASE_URL
|
||||||
|
|
||||||
# Обновляем URL базы данных из переменных окружения
|
# Обновляем URL базы данных из переменных окружения
|
||||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
config.set_main_option("sqlalchemy.url", DATABASE_URL.replace("%", "%%"))
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
@@ -91,4 +91,4 @@ def run_migrations_online() -> None:
|
|||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
run_migrations_offline()
|
run_migrations_offline()
|
||||||
else:
|
else:
|
||||||
run_migrations_online()
|
run_migrations_online()
|
||||||
|
|||||||
@@ -17,72 +17,12 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# All objects in this autogenerated revision were already introduced by
|
||||||
op.create_table('blocked_users',
|
# 71376bb89294, 1f1631301809, and b4c435a7dc5f. Keep the revision marker
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
# for deployed databases without creating the same objects twice.
|
||||||
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
|
pass
|
||||||
sa.Column('error_type', sa.String(length=100), nullable=False),
|
|
||||||
sa.Column('error_message', sa.Text(), nullable=True),
|
|
||||||
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('attempt_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
|
|
||||||
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
|
|
||||||
op.create_table('broadcast_channels',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column('chat_type', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('username', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('added_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
|
|
||||||
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
|
|
||||||
op.create_table('broadcast_logs',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('target_id', sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column('message_type', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('message_text', sa.Text(), nullable=True),
|
|
||||||
sa.Column('file_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('total_recipients', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('success_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('failed_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('blocked_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=20), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
|
|
||||||
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
|
|
||||||
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=True))
|
|
||||||
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# The preceding revisions own these objects.
|
||||||
op.drop_column('users', 'last_activity')
|
pass
|
||||||
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 ###
|
|
||||||
|
|||||||
50
migrations/versions/20260701_0001_add_performance_indexes.py
Normal file
50
migrations/versions/20260701_0001_add_performance_indexes.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""add performance indexes
|
||||||
|
|
||||||
|
Revision ID: 20260701_perf_indexes
|
||||||
|
Revises: 20260307_0100_add_emoji_mappings
|
||||||
|
Create Date: 2026-07-01 00:01:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = '20260701_perf_indexes'
|
||||||
|
down_revision = '20260307_0100_add_emoji_mappings'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_users_registered_activity "
|
||||||
|
"ON users (is_registered, last_activity)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_participations_lottery_account "
|
||||||
|
"ON participations (lottery_id, account_number)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_participations_user_created "
|
||||||
|
"ON participations (user_id, created_at)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_winners_lottery_place "
|
||||||
|
"ON winners (lottery_id, place)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_winners_user_id "
|
||||||
|
"ON winners (user_id)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_blocked_users_lookup "
|
||||||
|
"ON blocked_users (telegram_id, error_type, is_active)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_blocked_users_lookup")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_winners_user_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_winners_lottery_place")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_participations_user_created")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_participations_lottery_account")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_users_registered_activity")
|
||||||
40
migrations/versions/20260913_staff_concurrency.py
Normal file
40
migrations/versions/20260913_staff_concurrency.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Merge legacy heads, add cashiers, prevent duplicate entries and prize places."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260913_staff_concurrency"
|
||||||
|
down_revision = ("20260701_perf_indexes", "41aae82e631b")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
# Stop safely on ambiguous historical results; never silently delete prizes.
|
||||||
|
checks = (
|
||||||
|
("participations", "lottery_id, account_number", "account_number IS NOT NULL"),
|
||||||
|
("participations", "lottery_id, user_id", "account_number IS NULL AND user_id IS NOT NULL"),
|
||||||
|
("winners", "lottery_id, place", "1=1"),
|
||||||
|
)
|
||||||
|
for table, columns, predicate in checks:
|
||||||
|
duplicate = connection.execute(sa.text(
|
||||||
|
f"SELECT 1 FROM {table} WHERE {predicate} GROUP BY {columns} HAVING COUNT(*) > 1 LIMIT 1"
|
||||||
|
)).first()
|
||||||
|
if duplicate:
|
||||||
|
raise RuntimeError(f"Duplicate {table} ({columns}); review duplicates before migrating. No data removed.")
|
||||||
|
op.add_column("users", sa.Column("is_cashier", sa.Boolean(), server_default=sa.false(), nullable=False))
|
||||||
|
with op.batch_alter_table("participations") as batch:
|
||||||
|
batch.create_unique_constraint("uq_participation_account", ["lottery_id", "account_number"])
|
||||||
|
op.create_index("uq_participation_user", "participations", ["lottery_id", "user_id"], unique=True,
|
||||||
|
postgresql_where=sa.text("account_number IS NULL"), sqlite_where=sa.text("account_number IS NULL"))
|
||||||
|
with op.batch_alter_table("winners") as batch:
|
||||||
|
batch.create_unique_constraint("uq_winner_place", ["lottery_id", "place"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("winners") as batch:
|
||||||
|
batch.drop_constraint("uq_winner_place", type_="unique")
|
||||||
|
op.drop_index("uq_participation_user", table_name="participations")
|
||||||
|
with op.batch_alter_table("participations") as batch:
|
||||||
|
batch.drop_constraint("uq_participation_account", type_="unique")
|
||||||
|
op.drop_column("users", "is_cashier")
|
||||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E9", "F821", "F822", "F823"]
|
||||||
6
requirements-dev.txt
Normal file
6
requirements-dev.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
|
ruff==0.16.7
|
||||||
|
pip-audit==2.10.1
|
||||||
|
PyYAML==6.0.3
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# Updated for Python 3.12 compatibility
|
# Updated for Python 3.12 compatibility
|
||||||
aiogram==3.16.0
|
aiogram==3.31.0
|
||||||
aiohttp>=3.11.0
|
aiohttp==3.14.3
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.2.2
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
aioredis==2.0.1
|
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
defusedxml==0.7.1
|
||||||
|
|||||||
26
scripts/backup_database.py
Normal file
26
scripts/backup_database.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Create a pg_dump backup without putting passwords in argv or output."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
url = make_url(os.environ["DATABASE_URL"])
|
||||||
|
if url.get_backend_name() != "postgresql":
|
||||||
|
raise SystemExit("Production deployment requires PostgreSQL")
|
||||||
|
path = Path(os.environ["BACKUP_FILE"])
|
||||||
|
environment = dict(os.environ, PGPASSWORD=url.password or "")
|
||||||
|
if "sslmode" in url.query:
|
||||||
|
environment["PGSSLMODE"] = url.query["sslmode"]
|
||||||
|
with path.open("xb") as backup:
|
||||||
|
path.chmod(0o600)
|
||||||
|
subprocess.run(["pg_dump", "--format=custom", "--no-password", "--host", url.host or "localhost",
|
||||||
|
"--port", str(url.port or 5432), "--username", url.username or "postgres",
|
||||||
|
"--dbname", url.database], env=environment, stdout=backup, check=True, timeout=600)
|
||||||
|
print("Database backup created")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
scripts/build_release.py
Normal file
27
scripts/build_release.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Build a release from an explicit allowlist, excluding local secrets and caches."""
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def build_release(destination=None):
|
||||||
|
destination = Path(destination or ROOT / "dist" / "lottery.tar.gz")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
allowed = ["src", "migrations", "scripts", "main.py", "requirements.txt", "alembic.ini",
|
||||||
|
"Dockerfile", "docker-compose.yml", ".dockerignore"]
|
||||||
|
with tarfile.open(destination, "w:gz") as archive:
|
||||||
|
for name in allowed:
|
||||||
|
entry = ROOT / name
|
||||||
|
paths = entry.rglob("*") if entry.is_dir() else [entry]
|
||||||
|
for path in sorted(paths):
|
||||||
|
if not path.is_file() or path.is_symlink() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||||
|
continue
|
||||||
|
archive.add(path, arcname=path.relative_to(ROOT).as_posix(), recursive=False)
|
||||||
|
print(f"Release SHA256: {hashlib.sha256(destination.read_bytes()).hexdigest()}")
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_release()
|
||||||
27
scripts/check_schema.py
Normal file
27
scripts/check_schema.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Fail deployment if migrations or runtime table definitions are missing."""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from src.core.database import engine, Base
|
||||||
|
from src.core import models
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
expected = set(ScriptDirectory.from_config(Config("alembic.ini")).get_heads())
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
actual = set((await connection.scalars(text("SELECT version_num FROM alembic_version"))).all())
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError("Database migrations are not at the release head")
|
||||||
|
for table in Base.metadata.sorted_tables:
|
||||||
|
await connection.execute(select(table).limit(0))
|
||||||
|
await engine.dispose()
|
||||||
|
print("Database schema verified")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
36
scripts/check_secrets.py
Normal file
36
scripts/check_secrets.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Scan tracked content without printing matched credentials."""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
RULES = {
|
||||||
|
"telegram-token": re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{32,}\b"),
|
||||||
|
"private-key": re.compile(r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
files = subprocess.check_output(["git", "ls-files", "-z"]).decode("utf-8").split("\0")
|
||||||
|
violations = []
|
||||||
|
for name in filter(None, files):
|
||||||
|
path = Path(name)
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path.name.startswith(".env") and not path.name.endswith(".example"):
|
||||||
|
violations.append(f"{name}: tracked runtime environment")
|
||||||
|
if ".history" in path.parts:
|
||||||
|
violations.append(f"{name}: tracked editor history")
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
for rule, pattern in RULES.items():
|
||||||
|
for match in pattern.finditer(text):
|
||||||
|
line = text.count("\n", 0, match.start()) + 1
|
||||||
|
violations.append(f"{name}:{line}: {rule}")
|
||||||
|
for violation in violations:
|
||||||
|
print(violation)
|
||||||
|
if violations:
|
||||||
|
raise SystemExit(1)
|
||||||
|
print("Tracked secret scan passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
63
scripts/configure_drone_secrets.py
Normal file
63
scripts/configure_drone_secrets.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Create/update repository secrets without putting their values in command arguments."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--server", required=True)
|
||||||
|
parser.add_argument("--repo", default="trevor/new_lottery_bot")
|
||||||
|
parser.add_argument("--token-file", type=Path)
|
||||||
|
parser.add_argument("--ssh-key", type=Path, required=True)
|
||||||
|
parser.add_argument("--known-hosts", type=Path, required=True)
|
||||||
|
parser.add_argument("--env-file", type=Path, default=Path(".env.prod"))
|
||||||
|
parser.add_argument("--deploy-user", required=True)
|
||||||
|
parser.add_argument("--deploy-path", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if urlsplit(args.server).scheme != "https":
|
||||||
|
parser.error("Drone must use HTTPS")
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repo):
|
||||||
|
parser.error("Invalid repository name")
|
||||||
|
token = args.token_file.read_text(encoding="utf-8").strip() if args.token_file else os.getenv("DRONE_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
parser.error("Provide --token-file or DRONE_TOKEN")
|
||||||
|
values = {
|
||||||
|
"lottery_deploy_user": args.deploy_user,
|
||||||
|
"lottery_deploy_path": args.deploy_path,
|
||||||
|
"lottery_deploy_ssh_key": args.ssh_key.read_text(encoding="utf-8"),
|
||||||
|
"lottery_deploy_known_hosts": args.known_hosts.read_text(encoding="utf-8"),
|
||||||
|
"lottery_env": args.env_file.read_text(encoding="utf-8-sig"),
|
||||||
|
}
|
||||||
|
if any(not value.strip() for value in values.values()):
|
||||||
|
parser.error("All secret values must be nonempty")
|
||||||
|
endpoint = args.server.rstrip("/") + f"/api/repos/{args.repo}/secrets"
|
||||||
|
def request(url, method="GET", body=None):
|
||||||
|
payload = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=payload, method=method,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
data = response.read()
|
||||||
|
return json.loads(data) if data else None
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raise SystemExit(f"Drone API rejected {method}: HTTP {error.code}. Secret values were not logged.") from None
|
||||||
|
existing = {item["name"] for item in request(endpoint)}
|
||||||
|
for name, value in values.items():
|
||||||
|
body = {"name": name, "data": value, "pull_request": False}
|
||||||
|
request(endpoint + "/" + name if name in existing else endpoint,
|
||||||
|
"PATCH" if name in existing else "POST", body)
|
||||||
|
print(f"Configured {name} (pull requests disabled)")
|
||||||
|
configured = {item["name"] for item in request(endpoint)}
|
||||||
|
if not values.keys() <= configured:
|
||||||
|
raise SystemExit("Drone secret verification failed")
|
||||||
|
print("All required repository secrets are present")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
61
scripts/deploy_release.sh
Normal file
61
scripts/deploy_release.sh
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
root=$1
|
||||||
|
upload=$2
|
||||||
|
commit=$3
|
||||||
|
build=$4
|
||||||
|
case "$root" in /opt/*|/srv/*|/home/*/*) ;; *) echo "Use a dedicated absolute deployment directory" >&2; exit 1;; esac
|
||||||
|
case "$root" in *..*|*[!a-zA-Z0-9_./-]*) echo "Invalid deployment path" >&2; exit 1;; esac
|
||||||
|
mkdir -p "$root/releases" "$root/backups"
|
||||||
|
root=$(cd "$root" && pwd -P)
|
||||||
|
exec 9>"$root/deploy.lock"
|
||||||
|
flock -w 600 9
|
||||||
|
if [ -f "$root/last-build" ] && [ "$build" -le "$(cat "$root/last-build")" ]; then
|
||||||
|
echo "A newer or identical build is already deployed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
release="$root/releases/$commit-$build"
|
||||||
|
mkdir -p "$release"
|
||||||
|
tar -xzf "$upload/lottery.tar.gz" -C "$release"
|
||||||
|
install -m 600 "$upload/runtime.env" "$release/.env.prod"
|
||||||
|
cd "$release"
|
||||||
|
export BOT_IMAGE="new_lottery_bot:$commit"
|
||||||
|
compose() { docker compose --project-name new_lottery_bot --env-file .env.prod "$@"; }
|
||||||
|
compose config --quiet
|
||||||
|
compose build --pull bot
|
||||||
|
old_image=$(docker inspect --format '{{.Image}}' lottery_bot 2>/dev/null || true)
|
||||||
|
old_project=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' lottery_bot 2>/dev/null || true)
|
||||||
|
if [ -n "$old_image" ] && [ "$old_project" != "new_lottery_bot" ]; then
|
||||||
|
echo "Existing lottery_bot belongs to another stack; align the Compose project before the first deployment" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
services=$(compose config --services | grep -v '^bot$')
|
||||||
|
# Service names come from this repository's trusted Compose file.
|
||||||
|
compose up -d --wait $services
|
||||||
|
compose run --rm --no-deps --user "$(id -u):$(id -g)" \
|
||||||
|
-v "$root/backups:/backups" -e BACKUP_FILE="/backups/$commit-$build-$(date -u +%Y%m%dT%H%M%SZ).dump" \
|
||||||
|
bot python scripts/backup_database.py
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
code=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$code" -ne 0 ] && [ -n "$old_image" ]; then
|
||||||
|
echo "Deployment failed; restoring previous application image" >&2
|
||||||
|
BOT_IMAGE="$old_image" compose up -d --no-build --no-deps bot || true
|
||||||
|
fi
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
trap rollback EXIT
|
||||||
|
compose stop bot
|
||||||
|
compose run --rm --no-deps bot python -m alembic upgrade head
|
||||||
|
compose run --rm --no-deps bot python scripts/check_schema.py
|
||||||
|
compose up -d --no-build --wait --wait-timeout 180 bot
|
||||||
|
docker exec lottery_bot python -m src.core.health
|
||||||
|
ln -s "$release" "$root/current.$build"
|
||||||
|
mv -Tf "$root/current.$build" "$root/current"
|
||||||
|
printf '%s\n' "$build" > "$root/last-build"
|
||||||
|
trap - EXIT
|
||||||
|
echo "Deployment verified: $commit (build $build)"
|
||||||
46
scripts/drone_deploy.py
Normal file
46
scripts/drone_deploy.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Upload a tested artifact and environment over authenticated SSH; never log secrets."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
required = ["DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS", "DEPLOY_PATH", "LOTTERY_ENV"]
|
||||||
|
for key in required:
|
||||||
|
if not os.getenv(key, "").strip():
|
||||||
|
raise SystemExit(f"Missing Drone secret/environment: {key}")
|
||||||
|
host, user = os.environ["DEPLOY_HOST"], os.environ["DEPLOY_USER"]
|
||||||
|
commit, build = os.environ["DRONE_COMMIT_SHA"], os.environ["DRONE_BUILD_NUMBER"]
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9.-]+", host) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", user):
|
||||||
|
raise SystemExit("Invalid deployment host/user")
|
||||||
|
if not re.fullmatch(r"[a-f0-9]{40}", commit) or not re.fullmatch(r"[0-9]+", build):
|
||||||
|
raise SystemExit("Invalid build identity")
|
||||||
|
destination = f"{user}@{host}"
|
||||||
|
remote = f"/tmp/lottery-deploy-{uuid.uuid4().hex}"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="lottery-ssh-") as directory:
|
||||||
|
directory = Path(directory)
|
||||||
|
key, known, environment = directory / "key", directory / "known_hosts", directory / "runtime.env"
|
||||||
|
for path, value in ((key, "DEPLOY_SSH_KEY"), (known, "DEPLOY_KNOWN_HOSTS"), (environment, "LOTTERY_ENV")):
|
||||||
|
path.write_text(os.environ[value].strip() + "\n", encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
options = ["-i", str(key), "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={known}",
|
||||||
|
"-o", "ConnectTimeout=15"]
|
||||||
|
def ssh(command):
|
||||||
|
subprocess.run(["ssh", *options, destination, command], check=True, timeout=1200)
|
||||||
|
ssh(f"umask 077; mkdir {shlex.quote(remote)}")
|
||||||
|
try:
|
||||||
|
subprocess.run(["scp", *options, "dist/lottery.tar.gz", "scripts/deploy_release.sh", str(environment),
|
||||||
|
f"{destination}:{remote}/"], check=True, timeout=300)
|
||||||
|
ssh("sh " + shlex.join([remote + "/deploy_release.sh", os.environ["DEPLOY_PATH"], remote, commit, build]))
|
||||||
|
finally:
|
||||||
|
# Remove only the exact uploaded secret, even when a deployment fails.
|
||||||
|
ssh("rm -f -- " + shlex.quote(remote + "/runtime.env"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
scripts/wait_database.py
Normal file
24
scripts/wait_database.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from sqlalchemy import text
|
||||||
|
from src.core.database import engine
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.execute(text("SELECT 1"))
|
||||||
|
await engine.dispose()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if attempt == 29:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -34,50 +34,12 @@ class LotteryServiceImpl(ILotteryService):
|
|||||||
created_at=datetime.now(timezone.utc)
|
created_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def conduct_draw(self, lottery_id: int) -> Dict[str, Any]:
|
async def conduct_draw(self, lottery_id):
|
||||||
"""Провести розыгрыш"""
|
from src.core.services import LotteryService
|
||||||
lottery = await self.lottery_repo.get_by_id(lottery_id)
|
result = await LotteryService.conduct_draw(self.lottery_repo.session, lottery_id)
|
||||||
if not lottery or lottery.is_completed:
|
winners = await self.winner_repo.get_by_lottery(lottery_id) if result else []
|
||||||
return {}
|
return {str(place): dict(info, winner=next(w for w in winners if w.place == place))
|
||||||
|
for place, info in result.items()}
|
||||||
# Получаем участников
|
|
||||||
participations = await self.participation_repo.get_by_lottery(lottery_id)
|
|
||||||
if not participations:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Проводим розыгрыш
|
|
||||||
random.shuffle(participations)
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
num_prizes = len(lottery.prizes) if lottery.prizes else 3
|
|
||||||
winners = participations[:num_prizes]
|
|
||||||
|
|
||||||
for i, participation in enumerate(winners):
|
|
||||||
place = i + 1
|
|
||||||
prize = lottery.prizes[i] if lottery.prizes and i < len(lottery.prizes) else f"Приз {place}"
|
|
||||||
|
|
||||||
# Создаем запись о победителе
|
|
||||||
winner = await self.winner_repo.create(
|
|
||||||
lottery_id=lottery_id,
|
|
||||||
user_id=participation.user_id,
|
|
||||||
account_number=participation.account_number,
|
|
||||||
place=place,
|
|
||||||
prize=prize,
|
|
||||||
is_manual=False
|
|
||||||
)
|
|
||||||
|
|
||||||
results[str(place)] = {
|
|
||||||
'winner': winner,
|
|
||||||
'user': participation.user,
|
|
||||||
'prize': prize
|
|
||||||
}
|
|
||||||
|
|
||||||
# Помечаем розыгрыш как завершенный
|
|
||||||
lottery.is_completed = True
|
|
||||||
lottery.draw_results = {str(k): v['prize'] for k, v in results.items()}
|
|
||||||
await self.lottery_repo.update(lottery)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def get_active_lotteries(self) -> List[Lottery]:
|
async def get_active_lotteries(self) -> List[Lottery]:
|
||||||
"""Получить активные розыгрыши"""
|
"""Получить активные розыгрыши"""
|
||||||
@@ -92,26 +54,13 @@ class UserServiceImpl(IUserService):
|
|||||||
|
|
||||||
async def get_or_create_user(self, telegram_id: int, **kwargs) -> User:
|
async def get_or_create_user(self, telegram_id: int, **kwargs) -> User:
|
||||||
"""Получить или создать пользователя"""
|
"""Получить или создать пользователя"""
|
||||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
from src.core.services import UserService
|
||||||
if not user:
|
return await UserService.get_or_create_user(self.user_repo.session, telegram_id, **kwargs)
|
||||||
user_data = {
|
|
||||||
'telegram_id': telegram_id,
|
|
||||||
'created_at': datetime.now(timezone.utc),
|
|
||||||
**kwargs
|
|
||||||
}
|
|
||||||
user = await self.user_repo.create(**user_data)
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def register_user(self, telegram_id: int, phone: str, club_card_number: str) -> bool:
|
async def register_user(self, telegram_id: int, phone: str, club_card_number: str) -> bool:
|
||||||
"""Зарегистрировать пользователя"""
|
from src.core.registration_services import RegistrationService
|
||||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
try:
|
||||||
if not user:
|
await RegistrationService.register_user(self.user_repo.session, telegram_id, club_card_number, phone)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
user.phone = phone
|
|
||||||
user.club_card_number = club_card_number
|
|
||||||
user.is_registered = True
|
|
||||||
user.generate_verification_code()
|
|
||||||
|
|
||||||
await self.user_repo.update(user)
|
|
||||||
return True
|
|
||||||
@@ -81,17 +81,20 @@ class MessageFormatterImpl(IMessageFormatter):
|
|||||||
|
|
||||||
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
||||||
"""Форматировать информацию о розыгрыше"""
|
"""Форматировать информацию о розыгрыше"""
|
||||||
text = f"🎲 **{lottery.title}**\n\n"
|
from html import escape
|
||||||
|
text = f"🎲 <b>{escape(lottery.title)}</b>\n\n"
|
||||||
|
|
||||||
if lottery.description:
|
if lottery.description:
|
||||||
text += f"📝 {lottery.description}\n\n"
|
text += f"📝 {escape(lottery.description[:1200])}\n\n"
|
||||||
|
|
||||||
text += f"👥 Участников: {participants_count}\n"
|
text += f"👥 Участников: {participants_count}\n"
|
||||||
|
|
||||||
if lottery.prizes:
|
if lottery.prizes:
|
||||||
text += "\n🏆 **Призы:**\n"
|
text += "\n🏆 <b>Призы:</b>\n"
|
||||||
for i, prize in enumerate(lottery.prizes, 1):
|
for i, prize in enumerate(lottery.prizes[:5], 1):
|
||||||
text += f"{i}. {prize}\n"
|
text += f"{i}. {escape(str(prize)[:100])}\n"
|
||||||
|
if len(lottery.prizes) > 5:
|
||||||
|
text += f"И ещё {len(lottery.prizes) - 5} призов\n"
|
||||||
|
|
||||||
status = "🟢 Активный" if lottery.is_active and not lottery.is_completed else "🔴 Завершен"
|
status = "🟢 Активный" if lottery.is_active and not lottery.is_completed else "🔴 Завершен"
|
||||||
text += f"\n📊 Статус: {status}"
|
text += f"\n📊 Статус: {status}"
|
||||||
@@ -134,4 +137,4 @@ class MessageFormatterImpl(IMessageFormatter):
|
|||||||
text += f"✅ Завершенных розыгрышей: {stats.get('completed_lotteries', 0)}\n"
|
text += f"✅ Завершенных розыгрышей: {stats.get('completed_lotteries', 0)}\n"
|
||||||
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
|
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from src.core.access import is_admin
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram import F
|
from aiogram import F
|
||||||
import logging
|
import logging
|
||||||
@@ -29,26 +30,27 @@ class BotController(IBotController):
|
|||||||
self.lottery_repo = lottery_repo
|
self.lottery_repo = lottery_repo
|
||||||
self.participation_repo = participation_repo
|
self.participation_repo = participation_repo
|
||||||
|
|
||||||
def is_admin(self, user_id: int) -> bool:
|
async def is_admin(self, user_id: int) -> bool:
|
||||||
"""Проверить, является ли пользователь администратором"""
|
"""Проверить, является ли пользователь администратором"""
|
||||||
return user_id in ADMIN_IDS
|
return await is_admin(user_id)
|
||||||
|
|
||||||
async def handle_start(self, message: Message):
|
async def handle_start(self, message: Message, actor=None):
|
||||||
"""Обработать команду /start"""
|
"""Обработать команду /start"""
|
||||||
from src.utils.keyboards import get_main_reply_keyboard
|
from src.utils.keyboards import get_main_reply_keyboard
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
actor = actor or message.from_user
|
||||||
user = await self.user_service.get_or_create_user(
|
user = await self.user_service.get_or_create_user(
|
||||||
telegram_id=message.from_user.id,
|
telegram_id=actor.id,
|
||||||
username=message.from_user.username,
|
username=actor.username,
|
||||||
first_name=message.from_user.first_name,
|
first_name=actor.first_name,
|
||||||
last_name=message.from_user.last_name
|
last_name=actor.last_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Логирование статуса регистрации
|
# Логирование статуса регистрации
|
||||||
logger.info(f"User {message.from_user.id}: is_registered={user.is_registered}, is_admin={self.is_admin(message.from_user.id)}")
|
staff_admin = await self.is_admin(actor.id)
|
||||||
|
|
||||||
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
||||||
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
||||||
@@ -60,13 +62,19 @@ class BotController(IBotController):
|
|||||||
|
|
||||||
# Inline клавиатура
|
# Inline клавиатура
|
||||||
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
is_admin=self.is_admin(message.from_user.id),
|
is_admin=staff_admin,
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
from src.core.access import is_staff
|
||||||
|
from aiogram.types import InlineKeyboardButton
|
||||||
|
if await is_staff(actor.id):
|
||||||
|
inline_keyboard.inline_keyboard.append([
|
||||||
|
InlineKeyboardButton(text="💼 Касса", callback_data="cashier_panel")
|
||||||
|
])
|
||||||
|
|
||||||
# Обычная клавиатура
|
# Обычная клавиатура
|
||||||
reply_keyboard = get_main_reply_keyboard(
|
reply_keyboard = get_main_reply_keyboard(
|
||||||
is_admin=self.is_admin(message.from_user.id),
|
is_admin=staff_admin,
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class BotController(IBotController):
|
|||||||
for lottery in lotteries:
|
for lottery in lotteries:
|
||||||
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
||||||
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
|
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
|
||||||
text += lottery_info + "\n" + "="*30 + "\n\n"
|
await callback.message.answer(lottery_info, parse_mode="HTML")
|
||||||
|
|
||||||
# Получаем информацию о регистрации пользователя
|
# Получаем информацию о регистрации пользователя
|
||||||
user = await self.user_service.get_or_create_user(
|
user = await self.user_service.get_or_create_user(
|
||||||
@@ -105,7 +113,7 @@ class BotController(IBotController):
|
|||||||
)
|
)
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_main_keyboard(
|
keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
is_admin=self.is_admin(callback.from_user.id),
|
is_admin=await self.is_admin(callback.from_user.id),
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
56
src/core/access.py
Normal file
56
src/core/access.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Single source of truth for staff permissions."""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .config import ADMIN_IDS, CASHIER_IDS
|
||||||
|
from .database import async_session_maker
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
current_role = ContextVar("current_staff_role", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_role(telegram_id: int) -> str:
|
||||||
|
if telegram_id in ADMIN_IDS:
|
||||||
|
return "super_admin"
|
||||||
|
cached = current_role.get()
|
||||||
|
if cached and cached[0] == telegram_id:
|
||||||
|
return cached[1]
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
row = (await session.execute(
|
||||||
|
select(User.is_admin, User.is_cashier).where(User.telegram_id == telegram_id)
|
||||||
|
)).first()
|
||||||
|
if row and row.is_admin:
|
||||||
|
return "admin"
|
||||||
|
if telegram_id in CASHIER_IDS or (row and row.is_cashier):
|
||||||
|
return "cashier"
|
||||||
|
return "user"
|
||||||
|
|
||||||
|
|
||||||
|
async def is_admin(telegram_id: int) -> bool:
|
||||||
|
return await get_role(telegram_id) in {"super_admin", "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
async def is_staff(telegram_id: int) -> bool:
|
||||||
|
return await get_role(telegram_id) in {"super_admin", "admin", "cashier"}
|
||||||
|
|
||||||
|
|
||||||
|
def require_access(check):
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(event, *args, **kwargs):
|
||||||
|
if not event.from_user or not await check(event.from_user.id):
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
|
else:
|
||||||
|
await event.answer("❌ Недостаточно прав")
|
||||||
|
return
|
||||||
|
return await func(event, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
admin_only = require_access(is_admin)
|
||||||
|
staff_only = require_access(is_staff)
|
||||||
@@ -84,35 +84,24 @@ class ActivityService:
|
|||||||
Количество помеченных пользователей
|
Количество помеченных пользователей
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
days = days if days is not None else ActivityService.INACTIVITY_PERIOD_DAYS
|
||||||
|
if session.bind.dialect.name == "postgresql":
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
else:
|
||||||
|
from sqlalchemy.dialects.sqlite import insert
|
||||||
inactive_users = await ActivityService.get_inactive_users(session, days)
|
inactive_users = await ActivityService.get_inactive_users(session, days)
|
||||||
marked_count = 0
|
marked_count = 0
|
||||||
|
|
||||||
for user in inactive_users:
|
for user in inactive_users:
|
||||||
# Проверяем, не помечен ли уже
|
now = datetime.now(timezone.utc)
|
||||||
stmt = select(BlockedUser).where(
|
values = dict(error_type='inactive', error_message=f'User inactive for {days} days',
|
||||||
and_(
|
last_attempt_at=now, attempt_count=1, is_active=True)
|
||||||
BlockedUser.telegram_id == user.telegram_id,
|
statement = insert(BlockedUser).values(telegram_id=user.telegram_id,
|
||||||
BlockedUser.error_type == 'inactive',
|
first_blocked_at=now, **values)
|
||||||
BlockedUser.is_active == True
|
result = await session.execute(statement.on_conflict_do_update(
|
||||||
)
|
index_elements=[BlockedUser.telegram_id], set_=values,
|
||||||
)
|
where=BlockedUser.is_active.is_(False)))
|
||||||
result = await session.execute(stmt)
|
marked_count += result.rowcount
|
||||||
existing = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not existing:
|
|
||||||
# Создаем новую запись
|
|
||||||
blocked = BlockedUser(
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
error_type='inactive',
|
|
||||||
error_message=f'User inactive for {days} days',
|
|
||||||
first_blocked_at=datetime.now(timezone.utc),
|
|
||||||
last_attempt_at=datetime.now(timezone.utc),
|
|
||||||
attempt_count=1,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
session.add(blocked)
|
|
||||||
marked_count += 1
|
|
||||||
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return marked_count
|
return marked_count
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Tuple, Any
|
from typing import Optional, List, Dict, Tuple, Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from src.utils.delivery import background_delivery
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||||
@@ -16,6 +17,7 @@ import redis.asyncio as redis
|
|||||||
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
||||||
from .config import REDIS_URL, ADMIN_IDS
|
from .config import REDIS_URL, ADMIN_IDS
|
||||||
from .database import async_session_maker
|
from .database import async_session_maker
|
||||||
|
from src.utils.telegram_messages import copy_preserving_entities
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -131,27 +133,16 @@ class BroadcastService:
|
|||||||
error_type: Тип ошибки
|
error_type: Тип ошибки
|
||||||
error_message: Сообщение об ошибке
|
error_message: Сообщение об ошибке
|
||||||
"""
|
"""
|
||||||
# Проверяем, есть ли уже запись
|
if session.bind.dialect.name == "postgresql":
|
||||||
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
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:
|
else:
|
||||||
# Создаем новую запись
|
from sqlalchemy.dialects.sqlite import insert
|
||||||
blocked_user = BlockedUser(
|
now = datetime.now(timezone.utc)
|
||||||
telegram_id=telegram_id,
|
statement = insert(BlockedUser).values(telegram_id=telegram_id, error_type=error_type,
|
||||||
error_type=error_type,
|
error_message=error_message, last_attempt_at=now, attempt_count=1, is_active=True)
|
||||||
error_message=error_message
|
await session.execute(statement.on_conflict_do_update(index_elements=[BlockedUser.telegram_id],
|
||||||
)
|
set_={"error_type": error_type, "error_message": error_message, "last_attempt_at": now,
|
||||||
session.add(blocked_user)
|
"attempt_count": BlockedUser.attempt_count + 1, "is_active": True}))
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
||||||
|
|
||||||
@@ -175,11 +166,13 @@ class BroadcastService:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"Пользователь {telegram_id} разблокирован")
|
logger.info(f"Пользователь {telegram_id} разблокирован")
|
||||||
|
|
||||||
|
@background_delivery
|
||||||
async def send_message_to_user(
|
async def send_message_to_user(
|
||||||
self,
|
self,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
user: User,
|
user: User,
|
||||||
message: Message
|
message: Message,
|
||||||
|
_retry: bool = False
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Отправить сообщение пользователю с обработкой ошибок
|
Отправить сообщение пользователю с обработкой ошибок
|
||||||
@@ -201,36 +194,8 @@ class BroadcastService:
|
|||||||
return False, "blocked"
|
return False, "blocked"
|
||||||
|
|
||||||
# Отправляем сообщение
|
# Отправляем сообщение
|
||||||
if message.text:
|
# Copy preserves Telegram entities and captions without reparsing user text.
|
||||||
await bot.send_message(
|
await copy_preserving_entities(message, user.telegram_id)
|
||||||
user.telegram_id,
|
|
||||||
message.text,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
elif message.photo:
|
|
||||||
await 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(
|
|
||||||
user.telegram_id,
|
|
||||||
video=message.video.file_id,
|
|
||||||
caption=message.caption,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
elif message.document:
|
|
||||||
await bot.send_document(
|
|
||||||
user.telegram_id,
|
|
||||||
document=message.document.file_id,
|
|
||||||
caption=message.caption,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Копируем сообщение как есть
|
|
||||||
await message.copy_to(user.telegram_id)
|
|
||||||
|
|
||||||
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -257,16 +222,20 @@ class BroadcastService:
|
|||||||
else:
|
else:
|
||||||
error_type = "bad_request"
|
error_type = "bad_request"
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
# A rejected custom emoji or malformed message does not mean the recipient blocked us.
|
||||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
if 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
|
return False, error_type
|
||||||
|
|
||||||
except TelegramRetryAfter as e:
|
except TelegramRetryAfter as e:
|
||||||
# FloodWait - слишком много запросов
|
# FloodWait - слишком много запросов
|
||||||
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
|
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
|
||||||
|
if _retry:
|
||||||
|
return False, "rate_limited"
|
||||||
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
|
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
|
||||||
# Повторная попытка
|
# Повторная попытка
|
||||||
return await self.send_message_to_user(bot, user, message)
|
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Другие ошибки
|
# Другие ошибки
|
||||||
@@ -416,32 +385,7 @@ class BroadcastService:
|
|||||||
log_id = broadcast_log.id
|
log_id = broadcast_log.id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Отправляем в канал
|
await copy_preserving_entities(message, channel_id)
|
||||||
if message.text:
|
|
||||||
await bot.send_message(channel_id, message.text, parse_mode="Markdown")
|
|
||||||
elif message.photo:
|
|
||||||
await bot.send_photo(
|
|
||||||
channel_id,
|
|
||||||
photo=message.photo[-1].file_id,
|
|
||||||
caption=message.caption,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
elif message.video:
|
|
||||||
await bot.send_video(
|
|
||||||
channel_id,
|
|
||||||
video=message.video.file_id,
|
|
||||||
caption=message.caption,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
elif message.document:
|
|
||||||
await bot.send_document(
|
|
||||||
channel_id,
|
|
||||||
document=message.document.file_id,
|
|
||||||
caption=message.caption,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await message.copy_to(channel_id)
|
|
||||||
|
|
||||||
# Обновляем лог
|
# Обновляем лог
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ class ChatSettingsService:
|
|||||||
"""Получить или создать настройки чата"""
|
"""Получить или создать настройки чата"""
|
||||||
settings = await ChatSettingsService.get_settings(session)
|
settings = await ChatSettingsService.get_settings(session)
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = ChatSettings(id=1, mode='broadcast', global_ban=False)
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
session.add(settings)
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
|
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||||
|
await session.execute(insert(ChatSettings).values(id=1, mode="broadcast", global_ban=False)
|
||||||
|
.on_conflict_do_nothing(index_elements=[ChatSettings.id]))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(settings)
|
settings = await ChatSettingsService.get_settings(session)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -73,7 +76,7 @@ class BanService:
|
|||||||
BannedUser.telegram_id == telegram_id,
|
BannedUser.telegram_id == telegram_id,
|
||||||
BannedUser.is_active == True
|
BannedUser.is_active == True
|
||||||
)
|
)
|
||||||
)
|
).limit(1)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none() is not None
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ class BanService:
|
|||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
) -> BannedUser:
|
) -> BannedUser:
|
||||||
"""Забанить пользователя"""
|
"""Забанить пользователя"""
|
||||||
|
await session.execute(update(User).where(User.id == user_id).values(is_chat_banned=True))
|
||||||
# Проверяем есть ли уже активный бан
|
# Проверяем есть ли уже активный бан
|
||||||
existing_ban = await session.execute(
|
existing_ban = await session.execute(
|
||||||
select(BannedUser).where(
|
select(BannedUser).where(
|
||||||
@@ -95,7 +99,7 @@ class BanService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = existing_ban.scalar_one_or_none()
|
existing = existing_ban.scalars().first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Обновляем причину
|
# Обновляем причину
|
||||||
@@ -120,6 +124,8 @@ class BanService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def unban_user(session: AsyncSession, telegram_id: int) -> bool:
|
async def unban_user(session: AsyncSession, telegram_id: int) -> bool:
|
||||||
"""Разбанить пользователя"""
|
"""Разбанить пользователя"""
|
||||||
|
user_result = await session.execute(update(User).where(User.telegram_id == telegram_id, User.is_chat_banned.is_(True))
|
||||||
|
.values(is_chat_banned=False))
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(BannedUser)
|
update(BannedUser)
|
||||||
.where(
|
.where(
|
||||||
@@ -131,7 +137,7 @@ class BanService:
|
|||||||
.values(is_active=False)
|
.values(is_active=False)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0 or user_result.rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]:
|
async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]:
|
||||||
@@ -189,47 +195,26 @@ class ChatMessageService:
|
|||||||
async def get_message_by_telegram_id(
|
async def get_message_by_telegram_id(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
telegram_message_id: int,
|
telegram_message_id: int,
|
||||||
user_id: Optional[int] = None
|
user_id: Optional[int] = None,
|
||||||
|
chat_id: Optional[int] = None,
|
||||||
) -> Optional[ChatMessage]:
|
) -> Optional[ChatMessage]:
|
||||||
"""
|
"""Telegram message IDs are unique only inside the receiving chat."""
|
||||||
Получить сообщение по telegram_message_id
|
from sqlalchemy import or_
|
||||||
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
|
query = select(ChatMessage).options(selectinload(ChatMessage.sender)).where(
|
||||||
"""
|
ChatMessage.is_deleted.is_(False))
|
||||||
# Сначала ищем по оригинальному telegram_message_id
|
if chat_id is not None:
|
||||||
query = select(ChatMessage).where(
|
query = query.where(or_(
|
||||||
ChatMessage.telegram_message_id == telegram_message_id
|
and_(ChatMessage.telegram_message_id == telegram_message_id,
|
||||||
)
|
ChatMessage.sender.has(User.telegram_id == chat_id)),
|
||||||
|
ChatMessage.forwarded_message_ids[str(chat_id)].as_integer() == telegram_message_id,
|
||||||
if user_id:
|
))
|
||||||
query = query.where(ChatMessage.user_id == user_id)
|
elif user_id is not None:
|
||||||
|
query = query.where(ChatMessage.user_id == user_id,
|
||||||
result = await session.execute(query)
|
ChatMessage.telegram_message_id == telegram_message_id)
|
||||||
message = result.scalar_one_or_none()
|
else:
|
||||||
|
return None
|
||||||
# Если нашли - возвращаем
|
matches = (await session.scalars(query.limit(2))).all()
|
||||||
if message:
|
return matches[0] if len(matches) == 1 else None
|
||||||
return message
|
|
||||||
|
|
||||||
# Если не нашли - ищем в forwarded_message_ids
|
|
||||||
# Загружаем все недавние сообщения и ищем в них
|
|
||||||
query = select(ChatMessage).where(
|
|
||||||
ChatMessage.forwarded_message_ids.isnot(None)
|
|
||||||
).order_by(ChatMessage.created_at.desc()).limit(100)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
messages = result.scalars().all()
|
|
||||||
|
|
||||||
# Ищем сообщение, где telegram_message_id есть в forwarded_message_ids
|
|
||||||
for msg in messages:
|
|
||||||
if msg.forwarded_message_ids:
|
|
||||||
for user_tid, fwd_msg_id in msg.forwarded_message_ids.items():
|
|
||||||
if fwd_msg_id == telegram_message_id:
|
|
||||||
return msg
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_messages(
|
async def get_user_messages(
|
||||||
@@ -368,6 +353,8 @@ class ChatPermissionService:
|
|||||||
|
|
||||||
if user and user.is_chat_banned:
|
if user and user.is_chat_banned:
|
||||||
return False, "Вы заблокированы и не можете отправлять сообщения в чат"
|
return False, "Вы заблокированы и не можете отправлять сообщения в чат"
|
||||||
|
if not user or not user.is_registered:
|
||||||
|
return False, "Для отправки сообщений пройдите регистрацию: /register"
|
||||||
|
|
||||||
# Проверяем личный бан (старая система через BannedUser)
|
# Проверяем личный бан (старая система через BannedUser)
|
||||||
is_banned = await BanService.is_banned(session, telegram_id)
|
is_banned = await BanService.is_banned(session, telegram_id)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ if not BOT_TOKEN:
|
|||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||||
|
FSM_TTL_SECONDS = int(os.getenv("FSM_TTL_SECONDS", "86400"))
|
||||||
|
|
||||||
# Администраторы
|
# Администраторы
|
||||||
ADMIN_IDS = []
|
ADMIN_IDS = []
|
||||||
@@ -27,6 +28,9 @@ if admin_ids_str:
|
|||||||
# Логирование
|
# Логирование
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
|
||||||
|
# Кассиры имеют доступ только к участникам, счетам и выдаче призов.
|
||||||
|
CASHIER_IDS = [int(value.strip()) for value in os.getenv("CASHIER_IDS", "").split(",") if value.strip()]
|
||||||
|
|
||||||
# Настройки бота
|
# Настройки бота
|
||||||
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
|
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
|
||||||
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -10,11 +11,27 @@ load_dotenv()
|
|||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||||
|
|
||||||
# Создаем асинхронный движок
|
# Создаем асинхронный движок
|
||||||
engine = create_async_engine(
|
engine_options = {"echo": False, "pool_pre_ping": True, "hide_parameters": True}
|
||||||
DATABASE_URL,
|
if DATABASE_URL.startswith("sqlite"):
|
||||||
echo=True, # Логирование SQL запросов
|
engine_options["connect_args"] = {"timeout": 30}
|
||||||
future=True,
|
else:
|
||||||
)
|
engine_options.update(
|
||||||
|
pool_size=int(os.getenv("DB_POOL_SIZE", "10")),
|
||||||
|
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "10")),
|
||||||
|
pool_timeout=float(os.getenv("DB_POOL_TIMEOUT", "15")),
|
||||||
|
pool_recycle=int(os.getenv("DB_POOL_RECYCLE", "1800")),
|
||||||
|
connect_args={"timeout": float(os.getenv("DB_CONNECT_TIMEOUT", "10")),
|
||||||
|
"command_timeout": float(os.getenv("DATABASE_TIMEOUT", "30"))},
|
||||||
|
)
|
||||||
|
engine = create_async_engine(DATABASE_URL, **engine_options)
|
||||||
|
|
||||||
|
if DATABASE_URL.startswith("sqlite"):
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def configure_sqlite(connection, _):
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=30000")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
# Создаем фабрику сессий
|
# Создаем фабрику сессий
|
||||||
async_session_maker = async_sessionmaker(
|
async_session_maker = async_sessionmaker(
|
||||||
@@ -33,9 +50,10 @@ async def get_session() -> AsyncSession:
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
"""Инициализация базы данных"""
|
"""Инициализация базы данных"""
|
||||||
|
from . import models # Register tables before create_all (development/tests only).
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
async def close_db():
|
async def close_db():
|
||||||
"""Закрытие соединения с базой данных"""
|
"""Закрытие соединения с базой данных"""
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from html import escape
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -33,15 +36,27 @@ class EmojiMappingService:
|
|||||||
Returns:
|
Returns:
|
||||||
Созданный объект EmojiMapping
|
Созданный объект EmojiMapping
|
||||||
"""
|
"""
|
||||||
|
if not emoji_text or len(emoji_text) > 10:
|
||||||
|
raise ValueError("Отправьте один эмодзи")
|
||||||
|
if not emoji_id or not emoji_id.isascii() or not emoji_id.isdigit() or len(emoji_id) > 32:
|
||||||
|
raise ValueError("Нужен настоящий custom_emoji_id из Telegram")
|
||||||
|
if description and len(description) > 255:
|
||||||
|
raise ValueError("Описание должно быть не длиннее 255 символов")
|
||||||
|
from .services import UserService
|
||||||
|
admin = await UserService.get_or_create_user(self.session, admin_id)
|
||||||
emoji = EmojiMapping(
|
emoji = EmojiMapping(
|
||||||
emoji_text=emoji_text,
|
emoji_text=emoji_text,
|
||||||
emoji_id=emoji_id,
|
emoji_id=emoji_id,
|
||||||
admin_id=admin_id,
|
admin_id=admin.id,
|
||||||
description=description,
|
description=description,
|
||||||
created_at=datetime.now(timezone.utc)
|
created_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
self.session.add(emoji)
|
self.session.add(emoji)
|
||||||
await self.session.commit()
|
try:
|
||||||
|
await self.session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await self.session.rollback()
|
||||||
|
raise ValueError("Этот эмодзи или его вариант уже зарегистрирован") from None
|
||||||
await self.session.refresh(emoji)
|
await self.session.refresh(emoji)
|
||||||
return emoji
|
return emoji
|
||||||
|
|
||||||
@@ -58,7 +73,7 @@ class EmojiMappingService:
|
|||||||
"""
|
"""
|
||||||
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
|
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
|
||||||
if admin_id:
|
if admin_id:
|
||||||
query = query.where(EmojiMapping.admin_id == admin_id)
|
query = query.where(EmojiMapping.admin.has(User.telegram_id == admin_id))
|
||||||
|
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
@@ -74,7 +89,7 @@ class EmojiMappingService:
|
|||||||
EmojiMapping объект или None
|
EmojiMapping объект или None
|
||||||
"""
|
"""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
|
select(EmojiMapping).options(selectinload(EmojiMapping.admin)).where(EmojiMapping.emoji_id == emoji_id)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@@ -89,14 +104,14 @@ class EmojiMappingService:
|
|||||||
Список EmojiMapping объектов
|
Список EmojiMapping объектов
|
||||||
"""
|
"""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(EmojiMapping).where(EmojiMapping.admin_id == admin_id)
|
select(EmojiMapping).where(EmojiMapping.admin.has(User.telegram_id == admin_id))
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_all_emojis(self) -> List[EmojiMapping]:
|
async def get_all_emojis(self) -> List[EmojiMapping]:
|
||||||
"""Получить все зарегистрированные эмодзи"""
|
"""Получить все зарегистрированные эмодзи"""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
|
select(EmojiMapping).options(selectinload(EmojiMapping.admin)).order_by(EmojiMapping.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -136,42 +151,39 @@ class EmojiMappingService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def replace_emojis_in_text(self, text: str) -> str:
|
async def replace_emojis_in_text(self, text: str) -> str:
|
||||||
"""
|
"""Render a plain-text template as safe Telegram HTML with saved custom emoji."""
|
||||||
Заменить все известные эмодзи на их emoji_id в тексте
|
|
||||||
|
|
||||||
Это используется перед отправкой сообщения в Telegram,
|
|
||||||
чтобы эмодзи выглядели так же, как их отправил админ
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Исходный текст с эмодзи
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Текст с заменой эмодзи на emoji_id
|
|
||||||
"""
|
|
||||||
# Получаем все эмодзи маппинги
|
|
||||||
emojis = await self.get_all_emojis()
|
emojis = await self.get_all_emojis()
|
||||||
|
mapping = {}
|
||||||
# Заменяем каждый эмодзи на его emoji_id
|
|
||||||
for emoji in emojis:
|
for emoji in emojis:
|
||||||
# Экранируем специальные символы если нужно
|
if emoji.emoji_id.isascii() and emoji.emoji_id.isdigit() and emoji.emoji_text:
|
||||||
if emoji.emoji_text in text:
|
mapping.setdefault(emoji.emoji_text, emoji.emoji_id)
|
||||||
# Замена с сохранением контекста - оборачиваем в специальные маркеры
|
if not mapping:
|
||||||
# Это позволит потом распознать что это эмодзи ID а не обычный текст
|
return escape(text)
|
||||||
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
|
pattern = re.compile("|".join(re.escape(key) for key in sorted(mapping, key=len, reverse=True)))
|
||||||
|
output, position = [], 0
|
||||||
return text
|
for match in pattern.finditer(text):
|
||||||
|
output.append(escape(text[position:match.start()]))
|
||||||
|
output.append(f'<tg-emoji emoji-id="{mapping[match[0]]}">{escape(match[0])}</tg-emoji>')
|
||||||
|
position = match.end()
|
||||||
|
output.append(escape(text[position:]))
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
async def restore_emojis_in_text(self, text: str) -> str:
|
async def restore_emojis_in_text(self, text: str) -> str:
|
||||||
"""
|
"""Recover plain text from rendered HTML or legacy |emoji_id| markers."""
|
||||||
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
Args:
|
class PlainText(HTMLParser):
|
||||||
text: Текст с emoji_id маркерами (|emoji_id|)
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
Returns:
|
self.parts = []
|
||||||
Текст с восстановленными эмодзи
|
|
||||||
"""
|
def handle_data(self, data):
|
||||||
# Получаем все эмодзи маппинги
|
self.parts.append(data)
|
||||||
|
|
||||||
|
parser = PlainText()
|
||||||
|
parser.feed(text)
|
||||||
|
parser.close()
|
||||||
|
text = "".join(parser.parts)
|
||||||
emojis = await self.get_all_emojis()
|
emojis = await self.get_all_emojis()
|
||||||
|
|
||||||
# Восстанавливаем каждый эмодзи из его ID
|
# Восстанавливаем каждый эмодзи из его ID
|
||||||
|
|||||||
30
src/core/health.py
Normal file
30
src/core/health.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Health reflects a live event loop and a usable database connection."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .database import async_session_maker
|
||||||
|
|
||||||
|
HEARTBEAT = Path(os.getenv("HEARTBEAT_FILE", "/tmp/lottery-heartbeat"))
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat():
|
||||||
|
while True:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
HEARTBEAT.write_text(str(time.time()), encoding="ascii")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
def healthy():
|
||||||
|
try:
|
||||||
|
return 0 <= time.time() - float(HEARTBEAT.read_text(encoding="ascii")) < 60
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(0 if healthy() else 1)
|
||||||
39
src/core/import_services.py
Normal file
39
src/core/import_services.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Import independent records without poisoning the transaction on a bad row."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError, DataError
|
||||||
|
|
||||||
|
from .database import async_session_maker
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
async def import_users(records):
|
||||||
|
added = updated = errors = 0
|
||||||
|
for record in records:
|
||||||
|
try:
|
||||||
|
telegram_id = int(record["telegram_id"])
|
||||||
|
if telegram_id <= 0 or telegram_id > 2**63 - 1:
|
||||||
|
raise ValueError("Invalid Telegram ID")
|
||||||
|
values = {key: value for key, value in record.items() if key in {
|
||||||
|
"username", "first_name", "last_name", "nickname", "phone", "club_card_number",
|
||||||
|
"is_registered", "verification_code"}}
|
||||||
|
limits = {"username": 255, "first_name": 255, "last_name": 255, "nickname": 100,
|
||||||
|
"phone": 20, "club_card_number": 50, "verification_code": 10}
|
||||||
|
for key, limit in limits.items():
|
||||||
|
if key in values and (not isinstance(values[key], str) or len(values[key]) > limit):
|
||||||
|
raise ValueError("Invalid field length")
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
|
||||||
|
is_new = user is None
|
||||||
|
if is_new:
|
||||||
|
user = User(telegram_id=telegram_id)
|
||||||
|
session.add(user)
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(user, key, value)
|
||||||
|
if user.is_registered and not user.verification_code:
|
||||||
|
user.generate_verification_code()
|
||||||
|
await session.commit()
|
||||||
|
added += int(is_new)
|
||||||
|
updated += int(not is_new)
|
||||||
|
except (ValueError, TypeError, IntegrityError, DataError):
|
||||||
|
errors += 1
|
||||||
|
return added, updated, errors
|
||||||
@@ -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, text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -19,6 +19,7 @@ class User(Base):
|
|||||||
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
||||||
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
|
is_cashier = Column(Boolean, default=False, server_default="false", nullable=False)
|
||||||
is_chat_banned = Column(Boolean, default=False) # Заблокирован ли в чате бота
|
is_chat_banned = Column(Boolean, default=False) # Заблокирован ли в чате бота
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
last_activity = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Последняя активность
|
last_activity = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Последняя активность
|
||||||
@@ -114,6 +115,11 @@ class Lottery(Base):
|
|||||||
class Participation(Base):
|
class Participation(Base):
|
||||||
"""Модель участия в розыгрыше"""
|
"""Модель участия в розыгрыше"""
|
||||||
__tablename__ = "participations"
|
__tablename__ = "participations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("lottery_id", "account_number", name="uq_participation_account"),
|
||||||
|
Index("uq_participation_user", "lottery_id", "user_id", unique=True,
|
||||||
|
postgresql_where=text("account_number IS NULL"), sqlite_where=text("account_number IS NULL")),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
@@ -136,6 +142,7 @@ class Participation(Base):
|
|||||||
class Winner(Base):
|
class Winner(Base):
|
||||||
"""Модель победителя розыгрыша"""
|
"""Модель победителя розыгрыша"""
|
||||||
__tablename__ = "winners"
|
__tablename__ = "winners"
|
||||||
|
__table_args__ = (UniqueConstraint("lottery_id", "place", name="uq_winner_place"),)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Система управления правами доступа к командам бота
|
Система управления правами доступа к командам бота
|
||||||
"""
|
"""
|
||||||
|
from src.core.access import is_admin
|
||||||
|
from src.core.access import admin_only
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка является ли пользователь администратором"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
def admin_only(func):
|
|
||||||
"""
|
|
||||||
Декоратор для команд, доступных только администраторам.
|
|
||||||
Если пользователь не админ - отправляется сообщение об отказе в доступе.
|
|
||||||
"""
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(message: Message, *args, **kwargs):
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
|
||||||
return
|
|
||||||
return await func(message, *args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def user_command(func):
|
def user_command(func):
|
||||||
@@ -177,7 +164,7 @@ def get_admin_commands_by_category():
|
|||||||
return commands_by_category
|
return commands_by_category
|
||||||
|
|
||||||
|
|
||||||
def format_commands_help(user_id: int) -> str:
|
async def format_commands_help(user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Форматировать справку по командам в зависимости от прав пользователя
|
Форматировать справку по командам в зависимости от прав пользователя
|
||||||
"""
|
"""
|
||||||
@@ -189,7 +176,7 @@ def format_commands_help(user_id: int) -> str:
|
|||||||
help_text += f"/{cmd} - {info['description']}\n"
|
help_text += f"/{cmd} - {info['description']}\n"
|
||||||
|
|
||||||
# Если админ - показываем административные команды
|
# Если админ - показываем административные команды
|
||||||
if is_admin(user_id):
|
if await is_admin(user_id):
|
||||||
help_text += "\n" + "=" * 30 + "\n\n"
|
help_text += "\n" + "=" * 30 + "\n\n"
|
||||||
help_text += "🔐 <b>Административные команды:</b>\n\n"
|
help_text += "🔐 <b>Административные команды:</b>\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""
|
"""Compatibility helpers for emoji-aware HTML templates.
|
||||||
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
|
|
||||||
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
|
|
||||||
|
|
||||||
Для использования премиум эмодзи:
|
Plain Unicode never identifies a custom emoji. Incoming chat and broadcast messages
|
||||||
1. Бот должен быть создан с премиум аккаунта
|
use their original Telegram entities via src.utils.telegram_messages.
|
||||||
2. Использовать эмодзи напрямую в тексте сообщений
|
Telegram decides availability from bot ownership/subscription or Fragment eligibility:
|
||||||
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
|
https://core.telegram.org/bots/api#formatting-options
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -16,7 +14,7 @@ from aiogram.enums import MessageEntityType
|
|||||||
class PremiumEmojiConfig:
|
class PremiumEmojiConfig:
|
||||||
"""Конфигурация поддержки премиум эмодзи"""
|
"""Конфигурация поддержки премиум эмодзи"""
|
||||||
|
|
||||||
# Флаг, что бот может использовать премиум эмодзи
|
# Поддержка в коде; этот флаг не проверяет права бота на стороне Telegram
|
||||||
SUPPORTS_PREMIUM_EMOJI = True
|
SUPPORTS_PREMIUM_EMOJI = True
|
||||||
|
|
||||||
# Стандартные parse_mode для автоматической поддержки эмодзи
|
# Стандартные parse_mode для автоматической поддержки эмодзи
|
||||||
@@ -60,7 +58,7 @@ def ensure_emoji_support(text: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Обработанный текст с поддержкой эмодзи
|
Обработанный текст с поддержкой эмодзи
|
||||||
"""
|
"""
|
||||||
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
|
# Уже подготовленные tg-emoji теги сохраняются; обычный символ не превращается в premium
|
||||||
# Эта функция может быть расширена для дополнительной обработки если нужно
|
# Эта функция может быть расширена для дополнительной обработки если нужно
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@@ -83,7 +81,9 @@ async def send_message_with_emoji(
|
|||||||
Returns:
|
Returns:
|
||||||
Результат отправки сообщения
|
Результат отправки сообщения
|
||||||
"""
|
"""
|
||||||
if parse_mode is None:
|
if kwargs.get("entities") is not None:
|
||||||
|
parse_mode = None
|
||||||
|
elif parse_mode is None:
|
||||||
parse_mode = get_parse_mode()
|
parse_mode = get_parse_mode()
|
||||||
|
|
||||||
# Убедиться что текст может содержать эмодзи
|
# Убедиться что текст может содержать эмодзи
|
||||||
|
|||||||
47
src/core/redraw_services.py
Normal file
47
src/core/redraw_services.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Replace expired unclaimed prizes atomically, preserving all other results."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import random
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from .models import Lottery, Participation, Winner, WinnerVerification
|
||||||
|
|
||||||
|
|
||||||
|
async def redraw_unclaimed(session, lottery_id):
|
||||||
|
locked = await session.execute(update(Lottery).where(Lottery.id == lottery_id, Lottery.is_completed.is_(True))
|
||||||
|
.values(is_completed=True).execution_options(synchronize_session=False))
|
||||||
|
if locked.rowcount != 1:
|
||||||
|
await session.rollback()
|
||||||
|
return []
|
||||||
|
# Synchronize with cashier confirmations, which UPDATE the same Winner row.
|
||||||
|
winners = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id)
|
||||||
|
.order_by(Winner.place).with_for_update().execution_options(populate_existing=True))).all())
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
def expired(winner):
|
||||||
|
created = winner.created_at
|
||||||
|
if created and created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
return not winner.is_claimed and winner.is_notified and created and created < cutoff
|
||||||
|
unclaimed = [winner for winner in winners if expired(winner)]
|
||||||
|
used = {("account", w.account_number) if w.account_number else ("user", w.user_id) for w in winners}
|
||||||
|
tickets = list((await session.scalars(select(Participation).where(Participation.lottery_id == lottery_id)
|
||||||
|
.options(selectinload(Participation.user)))).all())
|
||||||
|
available = [p for p in tickets if (("account", p.account_number) if p.account_number else ("user", p.user_id)) not in used]
|
||||||
|
random.SystemRandom().shuffle(available)
|
||||||
|
lottery = await session.get(Lottery, lottery_id)
|
||||||
|
results = dict(lottery.draw_results or {})
|
||||||
|
replacements = []
|
||||||
|
for previous, ticket in zip(unclaimed, available):
|
||||||
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == previous.id))
|
||||||
|
await session.delete(previous)
|
||||||
|
await session.flush()
|
||||||
|
winner = Winner(lottery_id=lottery_id, user_id=ticket.user_id, account_number=ticket.account_number,
|
||||||
|
place=previous.place, prize=previous.prize, is_manual=False)
|
||||||
|
session.add(winner)
|
||||||
|
replacements.append(winner)
|
||||||
|
results[str(winner.place)] = dict(user_id=ticket.user_id, account_number=ticket.account_number,
|
||||||
|
telegram_id=ticket.user.telegram_id if ticket.user else None, prize=winner.prize, is_manual=False)
|
||||||
|
lottery.draw_results = results
|
||||||
|
await session.commit()
|
||||||
|
return replacements
|
||||||
@@ -4,6 +4,8 @@ from sqlalchemy import select, and_
|
|||||||
from .models import User, Account, Winner, WinnerVerification
|
from .models import User, Account, Winner, WinnerVerification
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from src.utils.account_utils import format_account_number
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +20,10 @@ class RegistrationService:
|
|||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Зарегистрировать нового пользователя с клубной картой"""
|
"""Зарегистрировать нового пользователя с клубной картой"""
|
||||||
|
if not club_card_number or not club_card_number.isascii() or not club_card_number.isdigit() or len(club_card_number) > 50:
|
||||||
|
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр")
|
||||||
|
if phone and len(phone) > 20:
|
||||||
|
raise ValueError("Телефон должен быть не длиннее 20 символов")
|
||||||
# Проверяем, не занята ли клубная карта
|
# Проверяем, не занята ли клубная карта
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(User).where(User.club_card_number == club_card_number)
|
select(User).where(User.club_card_number == club_card_number)
|
||||||
@@ -40,7 +46,11 @@ class RegistrationService:
|
|||||||
user.is_registered = True
|
user.is_registered = True
|
||||||
user.generate_verification_code()
|
user.generate_verification_code()
|
||||||
|
|
||||||
await session.commit()
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
@@ -78,6 +88,9 @@ class AccountService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||||
|
account_number = format_account_number(account_number)
|
||||||
|
if not account_number:
|
||||||
|
raise ValueError("Некорректный номер счета: требуется 14 цифр")
|
||||||
# Находим владельца по клубной карте
|
# Находим владельца по клубной карте
|
||||||
user_result = await session.execute(
|
user_result = await session.execute(
|
||||||
select(User).where(User.club_card_number == club_card_number)
|
select(User).where(User.club_card_number == club_card_number)
|
||||||
@@ -101,7 +114,11 @@ class AccountService:
|
|||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
session.add(account)
|
session.add(account)
|
||||||
await session.commit()
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
raise ValueError("Счет уже существует") from None
|
||||||
await session.refresh(account)
|
await session.refresh(account)
|
||||||
|
|
||||||
return account
|
return account
|
||||||
@@ -112,6 +129,9 @@ class AccountService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Найти владельца счета"""
|
"""Найти владельца счета"""
|
||||||
|
account_number = format_account_number(account_number)
|
||||||
|
if not account_number:
|
||||||
|
return None
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Account).where(
|
select(Account).where(
|
||||||
and_(
|
and_(
|
||||||
@@ -201,74 +221,33 @@ class WinnerNotificationService:
|
|||||||
return verification
|
return verification
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def verify_winner(
|
async def verify_winner(session, verification_code, lottery_id):
|
||||||
session: AsyncSession,
|
"""An atomic conditional update prevents two cashiers claiming the same prize."""
|
||||||
verification_code: str,
|
from sqlalchemy import update, or_
|
||||||
lottery_id: int
|
from .models import Lottery
|
||||||
) -> Optional[Winner]:
|
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == lottery_id)):
|
||||||
"""Подтвердить выигрыш по коду верификации пользователя"""
|
return None
|
||||||
# Находим пользователя по коду
|
user = await session.scalar(select(User).where(User.verification_code == verification_code.strip().upper()))
|
||||||
user_result = await session.execute(
|
|
||||||
select(User).where(User.verification_code == verification_code)
|
|
||||||
)
|
|
||||||
user = user_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
accounts = select(Account.account_number).where(Account.owner_id == user.id)
|
||||||
# Находим выигрыш этого пользователя в данном розыгрыше
|
# A user may win on several accounts. Claim one specific, ordered prize per call.
|
||||||
winner_result = await session.execute(
|
winner_id = await session.scalar(select(Winner.id).where(
|
||||||
select(Winner).where(
|
Winner.lottery_id == lottery_id, Winner.is_claimed.is_(False),
|
||||||
and_(
|
or_(Winner.user_id == user.id, and_(Winner.user_id.is_(None), Winner.account_number.in_(accounts)))
|
||||||
Winner.user_id == user.id,
|
).order_by(Winner.place).limit(1))
|
||||||
Winner.lottery_id == lottery_id,
|
if winner_id is None:
|
||||||
Winner.is_claimed == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
winner = winner_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not winner:
|
|
||||||
# Проверяем, может быть выигрыш по счету
|
|
||||||
# Получаем все счета пользователя
|
|
||||||
accounts_result = await session.execute(
|
|
||||||
select(Account).where(Account.owner_id == user.id)
|
|
||||||
)
|
|
||||||
accounts = accounts_result.scalars().all()
|
|
||||||
account_numbers = [acc.account_number for acc in accounts]
|
|
||||||
|
|
||||||
# Ищем выигрыш по любому из счетов
|
|
||||||
winner_result = await session.execute(
|
|
||||||
select(Winner).where(
|
|
||||||
and_(
|
|
||||||
Winner.account_number.in_(account_numbers),
|
|
||||||
Winner.lottery_id == lottery_id,
|
|
||||||
Winner.is_claimed == False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
winner = winner_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not winner:
|
|
||||||
return None
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
# Помечаем как подтвержденный
|
changed = await session.execute(update(Winner).where(Winner.id == winner_id, Winner.is_claimed.is_(False))
|
||||||
winner.is_claimed = True
|
.values(is_claimed=True, claimed_at=now))
|
||||||
|
if changed.rowcount != 1:
|
||||||
# Обновляем верификацию если есть
|
await session.rollback()
|
||||||
verification_result = await session.execute(
|
return None
|
||||||
select(WinnerVerification).where(WinnerVerification.winner_id == winner.id)
|
await session.execute(update(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
|
||||||
)
|
.values(is_verified=True, verified_at=now))
|
||||||
verification = verification_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if verification:
|
|
||||||
verification.is_verified = True
|
|
||||||
verification.verified_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(winner)
|
return await session.get(Winner, winner_id)
|
||||||
|
|
||||||
return winner
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_unverified_winners(
|
async def get_unverified_winners(
|
||||||
|
|||||||
@@ -11,39 +11,22 @@ class UserService:
|
|||||||
"""Сервис для работы с пользователями"""
|
"""Сервис для работы с пользователями"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_user(session: AsyncSession, telegram_id: int,
|
async def get_or_create_user(session, telegram_id, username=None, first_name=None, last_name=None, nickname=None):
|
||||||
username: str = None, first_name: str = None,
|
"""Upsert without racing or erasing metadata when only an ID is supplied."""
|
||||||
last_name: str = None, nickname: str = None) -> User:
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
"""Получить или создать пользователя"""
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
# Пробуем найти существующего пользователя
|
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||||
result = await session.execute(
|
values = {key: value for key, value in dict(username=username, first_name=first_name,
|
||||||
select(User).where(User.telegram_id == telegram_id)
|
last_name=last_name, nickname=nickname).items() if value is not None}
|
||||||
)
|
stmt = insert(User).values(telegram_id=telegram_id, **values)
|
||||||
user = result.scalar_one_or_none()
|
if values:
|
||||||
|
stmt = stmt.on_conflict_do_update(index_elements=[User.telegram_id], set_=values)
|
||||||
if user:
|
else:
|
||||||
# Обновляем информацию о пользователе
|
stmt = stmt.on_conflict_do_nothing(index_elements=[User.telegram_id])
|
||||||
user.username = username
|
await session.execute(stmt)
|
||||||
user.first_name = first_name
|
|
||||||
user.last_name = last_name
|
|
||||||
# Обновляем nickname только если он передан
|
|
||||||
if nickname is not None:
|
|
||||||
user.nickname = nickname
|
|
||||||
await session.commit()
|
|
||||||
return user
|
|
||||||
|
|
||||||
# Создаем нового пользователя
|
|
||||||
user = User(
|
|
||||||
telegram_id=telegram_id,
|
|
||||||
username=username,
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
nickname=nickname
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
return (await session.scalars(select(User).options(selectinload(User.accounts))
|
||||||
return user
|
.where(User.telegram_id == telegram_id).execution_options(populate_existing=True))).one()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||||
@@ -101,6 +84,14 @@ class UserService:
|
|||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Keep records needed by draws, claims, chat history, or staff audit trails.
|
||||||
|
# Cleanup may delete only users without linked data; moderation uses bans.
|
||||||
|
from .database import Base
|
||||||
|
for table in Base.metadata.sorted_tables:
|
||||||
|
for foreign_key in table.foreign_keys:
|
||||||
|
if foreign_key.column.table.name == "users":
|
||||||
|
if await session.scalar(select(table.c.id).where(foreign_key.parent == user_id).limit(1)):
|
||||||
|
return False
|
||||||
# Удаляем все участия
|
# Удаляем все участия
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(Participation).where(Participation.user_id == user_id)
|
delete(Participation).where(Participation.user_id == user_id)
|
||||||
@@ -128,72 +119,41 @@ class UserService:
|
|||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
|
async def set_account_number(session, telegram_id, account_number):
|
||||||
"""Установить номер клиентского счета пользователю"""
|
from sqlalchemy.exc import IntegrityError
|
||||||
# Валидируем и форматируем номер
|
formatted = format_account_number(account_number)
|
||||||
formatted_number = format_account_number(account_number)
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
if not formatted_number:
|
if not formatted or not user:
|
||||||
return False
|
return False
|
||||||
|
if await session.scalar(select(Account.id).where(Account.account_number == formatted)):
|
||||||
# Проверяем уникальность номера
|
return False
|
||||||
existing = await session.execute(
|
session.add(Account(account_number=formatted, owner_id=user.id))
|
||||||
select(User).where(User.account_number == formatted_number)
|
try:
|
||||||
)
|
await session.commit()
|
||||||
if existing.scalar_one_or_none():
|
except IntegrityError:
|
||||||
return False # Номер уже занят
|
await session.rollback()
|
||||||
|
return False
|
||||||
# Обновляем пользователя
|
return True
|
||||||
result = await session.execute(
|
|
||||||
update(User)
|
|
||||||
.where(User.telegram_id == telegram_id)
|
|
||||||
.values(account_number=formatted_number)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return result.rowcount > 0
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
|
async def get_user_by_account(session, account_number):
|
||||||
"""Получить пользователя по номеру счета"""
|
formatted = format_account_number(account_number)
|
||||||
formatted_number = format_account_number(account_number)
|
if not formatted:
|
||||||
if not formatted_number:
|
|
||||||
return None
|
return None
|
||||||
|
return await session.scalar(select(User).join(Account).options(selectinload(User.accounts)).where(
|
||||||
|
Account.account_number == formatted, Account.is_active.is_(True)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
async def get_user_by_club_card(session, club_card_number):
|
||||||
"""
|
return await session.scalar(select(User).where(User.club_card_number == club_card_number))
|
||||||
Получить пользователя по номеру клубной карты
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Сессия БД
|
|
||||||
club_card_number: Номер клубной карты (4 цифры)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User или None если не найден
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
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
|
@staticmethod
|
||||||
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
|
async def search_by_account(session, account_pattern):
|
||||||
"""Поиск пользователей по части номера счета"""
|
clean = ''.join(c for c in account_pattern if c in '0123456789-')
|
||||||
# Убираем все кроме цифр и дефисов
|
if not clean:
|
||||||
clean_pattern = ''.join(c for c in account_pattern if c.isdigit() or c == '-')
|
|
||||||
if not clean_pattern:
|
|
||||||
return []
|
return []
|
||||||
|
return list((await session.scalars(select(User).join(Account).distinct()
|
||||||
result = await session.execute(
|
.where(Account.account_number.contains(clean, autoescape=True)).limit(20))).all())
|
||||||
select(User).where(
|
|
||||||
User.account_number.like(f'%{clean_pattern}%')
|
|
||||||
).limit(20)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
class LotteryService:
|
class LotteryService:
|
||||||
@@ -221,6 +181,7 @@ class LotteryService:
|
|||||||
select(Lottery)
|
select(Lottery)
|
||||||
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
||||||
.where(Lottery.id == lottery_id)
|
.where(Lottery.id == lottery_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -245,14 +206,16 @@ class LotteryService:
|
|||||||
**updates
|
**updates
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Обновить данные розыгрыша"""
|
"""Обновить данные розыгрыша"""
|
||||||
|
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
await session.execute(
|
result = await session.execute(
|
||||||
update(Lottery)
|
update(Lottery)
|
||||||
.where(Lottery.id == lottery_id)
|
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||||||
.values(**updates)
|
.values(**updates)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return result.rowcount > 0
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return False
|
return False
|
||||||
@@ -269,126 +232,90 @@ class LotteryService:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def set_manual_winner(session: AsyncSession, lottery_id: int,
|
async def set_manual_winner(session, lottery_id, place, telegram_id):
|
||||||
place: int, telegram_id: int) -> bool:
|
from .transactions import lock_open_lottery
|
||||||
"""Установить ручного победителя для определенного места"""
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
# Получаем пользователя
|
await session.rollback()
|
||||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
|
||||||
if not user:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Получаем розыгрыш
|
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
if not lottery:
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
if not user or place < 1 or place > len(lottery.prizes or [None]):
|
||||||
|
await session.rollback()
|
||||||
return False
|
return False
|
||||||
|
if not any(p.user_id == user.id for p in lottery.participations):
|
||||||
# Обновляем ручных победителей
|
await session.rollback()
|
||||||
if not lottery.manual_winners:
|
return False
|
||||||
lottery.manual_winners = {}
|
# Reassign JSON: in-place edits are not tracked by a plain JSON column.
|
||||||
|
winners = dict(lottery.manual_winners or {})
|
||||||
lottery.manual_winners[str(place)] = telegram_id
|
if telegram_id in (value for key, value in winners.items() if key != str(place)):
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
winners[str(place)] = telegram_id
|
||||||
|
lottery.manual_winners = winners
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
|
async def conduct_draw(session, lottery_id):
|
||||||
"""Провести розыгрыш с учетом ручных победителей"""
|
"""Select and persist winners once, in one transaction, before notifications."""
|
||||||
import logging
|
from types import SimpleNamespace
|
||||||
logger = logging.getLogger(__name__)
|
from datetime import datetime, timezone
|
||||||
|
from .transactions import lock_open_lottery
|
||||||
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
|
from .models import WinnerVerification
|
||||||
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
|
await session.rollback()
|
||||||
|
return {}
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
if not lottery or lottery.is_completed:
|
participations = list(lottery.participations)
|
||||||
logger.warning(f"conduct_draw: lottery не найден или завершён")
|
if not participations:
|
||||||
|
await session.rollback()
|
||||||
return {}
|
return {}
|
||||||
|
num_prizes = len(lottery.prizes or [None])
|
||||||
logger.info(f"conduct_draw: получаем участников")
|
existing = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id))).all())
|
||||||
# Получаем всех участников (включая тех, у кого нет user)
|
if any(w.is_claimed for w in existing):
|
||||||
participants = []
|
raise ValueError("В розыгрыше уже есть выданные призы")
|
||||||
for p in lottery.participations:
|
selected = {}
|
||||||
if p.user:
|
available = participations.copy()
|
||||||
participants.append(p.user)
|
|
||||||
else:
|
|
||||||
# Создаем временный объект для участников без пользователя
|
|
||||||
# Храним только номер счета
|
|
||||||
participants.append(type('obj', (object,), {
|
|
||||||
'id': None,
|
|
||||||
'telegram_id': None,
|
|
||||||
'account_number': p.account_number
|
|
||||||
})())
|
|
||||||
|
|
||||||
logger.info(f"conduct_draw: участников {len(participants)}")
|
|
||||||
if not participants:
|
|
||||||
logger.warning(f"conduct_draw: нет участников")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Определяем количество призовых мест
|
|
||||||
num_prizes = len(lottery.prizes) if lottery.prizes else 1
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
remaining_participants = participants.copy()
|
|
||||||
manual_winners = lottery.manual_winners or {}
|
|
||||||
|
|
||||||
# Сначала обрабатываем ручных победителей
|
|
||||||
for place in range(1, num_prizes + 1):
|
for place in range(1, num_prizes + 1):
|
||||||
place_str = str(place)
|
preset = next((w for w in existing if w.place == place and w.is_manual), None)
|
||||||
if place_str in manual_winners:
|
manual = (lottery.manual_winners or {}).get(str(place))
|
||||||
# Находим пользователя среди участников
|
candidate = next((p for p in available if
|
||||||
manual_winner = None
|
(preset and preset.account_number and p.account_number == preset.account_number) or
|
||||||
for participant in remaining_participants:
|
(manual and p.user and p.user.telegram_id == manual)), None)
|
||||||
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
|
if candidate:
|
||||||
manual_winner = participant
|
selected[place] = (candidate, True)
|
||||||
break
|
available.remove(candidate)
|
||||||
|
rng = random.SystemRandom()
|
||||||
if manual_winner:
|
|
||||||
results[place] = {
|
|
||||||
'user': manual_winner,
|
|
||||||
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
|
|
||||||
'is_manual': True
|
|
||||||
}
|
|
||||||
remaining_participants.remove(manual_winner)
|
|
||||||
|
|
||||||
# Заполняем оставшиеся места случайными участниками
|
|
||||||
for place in range(1, num_prizes + 1):
|
for place in range(1, num_prizes + 1):
|
||||||
if place not in results and remaining_participants:
|
if place not in selected and available:
|
||||||
winner = random.choice(remaining_participants)
|
candidate = rng.choice(available)
|
||||||
results[place] = {
|
available.remove(candidate)
|
||||||
'user': winner,
|
selected[place] = (candidate, False)
|
||||||
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
|
if existing:
|
||||||
'is_manual': False
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_([w.id for w in existing])))
|
||||||
}
|
await session.execute(delete(Winner).where(Winner.lottery_id == lottery_id))
|
||||||
remaining_participants.remove(winner)
|
results, stored = {}, {}
|
||||||
|
for place, (participation, manual) in sorted(selected.items()):
|
||||||
# Сохраняем победителей в базу данных
|
owner = participation.user
|
||||||
for place, winner_info in results.items():
|
# Preserve the selected ticket, including for users with multiple accounts.
|
||||||
user_obj = winner_info['user']
|
actor = SimpleNamespace(id=participation.user_id,
|
||||||
winner = Winner(
|
telegram_id=owner.telegram_id if owner else None,
|
||||||
lottery_id=lottery_id,
|
username=owner.username if owner else None,
|
||||||
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
first_name=owner.first_name if owner else None,
|
||||||
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
nickname=owner.nickname if owner else None,
|
||||||
place=place,
|
account_number=participation.account_number)
|
||||||
prize=winner_info['prize'],
|
prize = lottery.prizes[place - 1] if lottery.prizes else f"Приз {place} места"
|
||||||
is_manual=winner_info['is_manual']
|
session.add(Winner(lottery_id=lottery_id, user_id=actor.id, account_number=actor.account_number,
|
||||||
)
|
place=place, prize=prize, is_manual=manual))
|
||||||
session.add(winner)
|
results[place] = dict(user=actor, prize=prize, is_manual=manual)
|
||||||
|
stored[str(place)] = dict(user_id=actor.id, telegram_id=actor.telegram_id,
|
||||||
# Обновляем статус розыгрыша
|
username=actor.username, account_number=actor.account_number,
|
||||||
logger.info(f"conduct_draw: обновляем статус lottery")
|
prize=prize, is_manual=manual)
|
||||||
lottery.is_completed = True
|
lottery.is_completed = True
|
||||||
lottery.draw_results = {}
|
lottery.is_active = False
|
||||||
for place, info in results.items():
|
lottery.end_date = datetime.now(timezone.utc)
|
||||||
user_obj = info['user']
|
lottery.draw_results = stored
|
||||||
lottery.draw_results[str(place)] = {
|
await session.commit()
|
||||||
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
|
||||||
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
|
|
||||||
'username': user_obj.username if hasattr(user_obj, 'username') else None,
|
|
||||||
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
|
||||||
'prize': info['prize'],
|
|
||||||
'is_manual': info['is_manual']
|
|
||||||
}
|
|
||||||
|
|
||||||
# НЕ коммитим здесь - это должно сделать вызывающая функция
|
|
||||||
logger.info(f"conduct_draw: изменения подготовлены, победителей: {len(results)}")
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -423,7 +350,7 @@ class LotteryService:
|
|||||||
"""Установить статус активности розыгрыша"""
|
"""Установить статус активности розыгрыша"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(Lottery)
|
update(Lottery)
|
||||||
.where(Lottery.id == lottery_id)
|
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||||||
.values(is_active=is_active)
|
.values(is_active=is_active)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -445,6 +372,9 @@ class LotteryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool:
|
async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool:
|
||||||
"""Удалить розыгрыш и все связанные данные"""
|
"""Удалить розыгрыш и все связанные данные"""
|
||||||
|
from .models import WinnerVerification
|
||||||
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(
|
||||||
|
select(Winner.id).where(Winner.lottery_id == lottery_id))))
|
||||||
# Сначала удаляем все связанные данные
|
# Сначала удаляем все связанные данные
|
||||||
# Удаляем победителей
|
# Удаляем победителей
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -469,36 +399,31 @@ class ParticipationService:
|
|||||||
"""Сервис для работы с участием в розыгрышах"""
|
"""Сервис для работы с участием в розыгрышах"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
async def add_participant(session, lottery_id, user_id):
|
||||||
"""Добавить участника в розыгрыш"""
|
from .transactions import lock_open_lottery
|
||||||
# Проверяем, не участвует ли уже пользователь
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
existing = await session.execute(
|
await session.rollback()
|
||||||
select(Participation)
|
|
||||||
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
return False
|
return False
|
||||||
|
exists = await session.scalar(select(Participation.id).where(
|
||||||
participation = Participation(lottery_id=lottery_id, user_id=user_id)
|
Participation.lottery_id == lottery_id, Participation.user_id == user_id).limit(1))
|
||||||
session.add(participation)
|
user = await session.get(User, user_id)
|
||||||
|
if exists or not user:
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
session.add(Participation(lottery_id=lottery_id, user_id=user_id))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def remove_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
async def remove_participant(session, lottery_id, user_id):
|
||||||
"""Удалить участника из розыгрыша"""
|
from .transactions import lock_open_lottery
|
||||||
participation = await session.execute(
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
select(Participation)
|
await session.rollback()
|
||||||
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
|
|
||||||
)
|
|
||||||
participation = participation.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not participation:
|
|
||||||
return False
|
return False
|
||||||
|
result = await session.execute(delete(Participation).where(
|
||||||
await session.delete(participation)
|
Participation.lottery_id == lottery_id, Participation.user_id == user_id))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return result.rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||||||
@@ -523,13 +448,9 @@ class ParticipationService:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
|
async def get_participants_count(session, lottery_id):
|
||||||
"""Получить количество участников в розыгрыше"""
|
from sqlalchemy import func
|
||||||
result = await session.execute(
|
return await session.scalar(select(func.count()).select_from(Participation).where(Participation.lottery_id == lottery_id))
|
||||||
select(Participation)
|
|
||||||
.where(Participation.lottery_id == lottery_id)
|
|
||||||
)
|
|
||||||
return len(result.scalars().all())
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
||||||
@@ -593,190 +514,24 @@ class ParticipationService:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
async def add_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||||||
"""Массовое добавление участников по номерам счетов"""
|
from src.handlers.account_services import AccountParticipationService
|
||||||
import logging
|
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, account_numbers)
|
||||||
logger = logging.getLogger(__name__)
|
result['invalid_accounts'] = [a for a in account_numbers if not a.split() or not format_account_number(a.split()[-1])]
|
||||||
|
return result
|
||||||
results = {
|
|
||||||
"added": 0,
|
|
||||||
"skipped": 0,
|
|
||||||
"errors": [],
|
|
||||||
"details": [],
|
|
||||||
"invalid_accounts": []
|
|
||||||
}
|
|
||||||
|
|
||||||
for account_input in account_numbers:
|
|
||||||
account_input = account_input.strip()
|
|
||||||
if not account_input:
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"DEBUG: Processing account_input={account_input!r}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
|
||||||
parts = account_input.split()
|
|
||||||
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
|
|
||||||
|
|
||||||
if len(parts) == 2:
|
|
||||||
card_number = parts[0] # Номер клубной карты
|
|
||||||
account_number = parts[1] # Номер счета
|
|
||||||
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
|
|
||||||
elif len(parts) == 1:
|
|
||||||
# Если нет пробела, считаем что это просто номер счета
|
|
||||||
card_number = None
|
|
||||||
account_number = parts[0]
|
|
||||||
logger.info(f"DEBUG: 1 part - account={account_number!r}")
|
|
||||||
else:
|
|
||||||
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
|
|
||||||
results["invalid_accounts"].append(account_input)
|
|
||||||
results["errors"].append(f"Неверный формат: {account_input}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Валидируем и форматируем номер счета
|
|
||||||
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
|
|
||||||
formatted_account = format_account_number(account_number)
|
|
||||||
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
|
|
||||||
|
|
||||||
if not formatted_account:
|
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
|
||||||
results["invalid_accounts"].append(account_input)
|
|
||||||
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
|
||||||
logger.error(f"DEBUG: Format failed for {account_number!r}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем владельца счёта через таблицу Account
|
|
||||||
from ..core.registration_services import AccountService
|
|
||||||
user = await AccountService.get_account_owner(session, formatted_account)
|
|
||||||
if not user:
|
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
|
||||||
results["errors"].append(f"Пользователь с счётом {formatted_account}{card_info} не найден")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Получаем запись Account для этого счета
|
|
||||||
account_record = await session.execute(
|
|
||||||
select(Account).where(Account.account_number == formatted_account)
|
|
||||||
)
|
|
||||||
account_record = account_record.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not account_record:
|
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
|
||||||
results["errors"].append(f"Запись счета {formatted_account}{card_info} не найдена в базе")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Проверяем, не участвует ли уже этот счет
|
|
||||||
existing = await session.execute(
|
|
||||||
select(Participation).where(
|
|
||||||
Participation.lottery_id == lottery_id,
|
|
||||||
Participation.account_number == formatted_account
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
results["skipped"] += 1
|
|
||||||
detail = f"{user.first_name} ({formatted_account})"
|
|
||||||
if card_number:
|
|
||||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
|
||||||
results["details"].append(f"Уже участвует: {detail}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Добавляем участие по счету
|
|
||||||
participation = Participation(
|
|
||||||
lottery_id=lottery_id,
|
|
||||||
user_id=user.id,
|
|
||||||
account_id=account_record.id,
|
|
||||||
account_number=formatted_account
|
|
||||||
)
|
|
||||||
session.add(participation)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
results["added"] += 1
|
|
||||||
detail = f"{user.first_name} ({formatted_account})"
|
|
||||||
if card_number:
|
|
||||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
|
||||||
results["details"].append(detail)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def remove_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
async def remove_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||||||
"""Массовое удаление участников по номерам счетов"""
|
from src.handlers.account_services import AccountParticipationService
|
||||||
results = {
|
result = dict(removed=0, not_found=0, errors=[], details=[], invalid_accounts=[])
|
||||||
"removed": 0,
|
for account in account_numbers:
|
||||||
"not_found": 0,
|
value = account.split()[-1] if account.split() else ''
|
||||||
"errors": [],
|
item = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, value)
|
||||||
"details": [],
|
result['removed' if item['success'] else 'not_found'] += 1
|
||||||
"invalid_accounts": []
|
result['details'].append(item['message'])
|
||||||
}
|
if not item['success']:
|
||||||
|
result['errors'].append(item['message'])
|
||||||
for account_input in account_numbers:
|
return result
|
||||||
account_input = account_input.strip()
|
|
||||||
if not account_input:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
|
||||||
parts = account_input.split()
|
|
||||||
if len(parts) == 2:
|
|
||||||
card_number = parts[0] # Номер клубной карты
|
|
||||||
account_number = parts[1] # Номер счета
|
|
||||||
elif len(parts) == 1:
|
|
||||||
# Если нет пробела, считаем что это просто номер счета
|
|
||||||
card_number = None
|
|
||||||
account_number = parts[0]
|
|
||||||
else:
|
|
||||||
results["invalid_accounts"].append(account_input)
|
|
||||||
results["errors"].append(f"Неверный формат: {account_input}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Валидируем и форматируем номер счета
|
|
||||||
formatted_account = format_account_number(account_number)
|
|
||||||
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}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем владельца счёта через таблицу Account
|
|
||||||
from ..core.registration_services import AccountService
|
|
||||||
user = await AccountService.get_account_owner(session, formatted_account)
|
|
||||||
if not user:
|
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
|
||||||
results["not_found"] += 1
|
|
||||||
results["details"].append(f"Не найден: {formatted_account}{card_info}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем участие по номеру счета (не по user_id!)
|
|
||||||
participation = await session.execute(
|
|
||||||
select(Participation).where(
|
|
||||||
Participation.lottery_id == lottery_id,
|
|
||||||
Participation.account_number == formatted_account
|
|
||||||
)
|
|
||||||
)
|
|
||||||
participation = participation.scalar_one_or_none()
|
|
||||||
|
|
||||||
if participation:
|
|
||||||
await session.delete(participation)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
results["removed"] += 1
|
|
||||||
detail = f"{user.first_name} ({formatted_account})"
|
|
||||||
if card_number:
|
|
||||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
|
||||||
results["details"].append(detail)
|
|
||||||
else:
|
|
||||||
results["not_found"] += 1
|
|
||||||
detail = f"{user.first_name} ({formatted_account})"
|
|
||||||
if card_number:
|
|
||||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
|
||||||
results["details"].append(f"Не участвовал: {detail}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||||||
@@ -804,4 +559,4 @@ class ParticipationService:
|
|||||||
"participations_count": participations_count,
|
"participations_count": participations_count,
|
||||||
"wins_count": wins_count,
|
"wins_count": wins_count,
|
||||||
"last_participation": last_participation.created_at if last_participation else None
|
"last_participation": last_participation.created_at if last_participation else None
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/core/transactions.py
Normal file
15
src/core/transactions.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Serialize mutations of one draw; unrelated users/draws remain concurrent."""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from .models import Lottery
|
||||||
|
|
||||||
|
|
||||||
|
async def lock_open_lottery(session, lottery_id: int) -> bool:
|
||||||
|
# UPDATE also serializes writers on SQLite, where FOR UPDATE is ignored.
|
||||||
|
result = await session.execute(
|
||||||
|
update(Lottery)
|
||||||
|
.where(Lottery.id == lottery_id, Lottery.is_active.is_(True), Lottery.is_completed.is_(False))
|
||||||
|
.values(is_active=Lottery.is_active)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from html import escape
|
||||||
"""
|
"""
|
||||||
Сервис управления пользователями с поиском и пагинацией
|
Сервис управления пользователями с поиском и пагинацией
|
||||||
"""
|
"""
|
||||||
@@ -159,6 +160,9 @@ class UserManagementService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
user.is_chat_banned = False
|
user.is_chat_banned = False
|
||||||
|
from .models import BannedUser
|
||||||
|
from sqlalchemy import update
|
||||||
|
await session.execute(update(BannedUser).where(BannedUser.telegram_id == user.telegram_id).values(is_active=False))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"Пользователь {user.telegram_id} разблокирован в чате")
|
logger.info(f"Пользователь {user.telegram_id} разблокирован в чате")
|
||||||
return True
|
return True
|
||||||
@@ -215,13 +219,13 @@ class UserManagementService:
|
|||||||
str: Форматированная информация
|
str: Форматированная информация
|
||||||
"""
|
"""
|
||||||
# Базовая информация
|
# Базовая информация
|
||||||
info = f"👤 <b>{user.first_name}"
|
info = f"👤 <b>{escape(user.first_name or '')}"
|
||||||
if user.last_name:
|
if user.last_name:
|
||||||
info += f" {user.last_name}"
|
info += f" {escape(user.last_name or '')}"
|
||||||
info += "</b>"
|
info += "</b>"
|
||||||
|
|
||||||
if user.username:
|
if user.username:
|
||||||
info += f" (@{user.username})"
|
info += f" (@{escape(user.username or '')})"
|
||||||
|
|
||||||
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
||||||
|
|
||||||
@@ -240,16 +244,19 @@ class UserManagementService:
|
|||||||
# Детальная информация
|
# Детальная информация
|
||||||
if detailed:
|
if detailed:
|
||||||
if user.nickname:
|
if user.nickname:
|
||||||
info += f"\n📝 Никнейм: {user.nickname}"
|
info += f"\n📝 Никнейм: {escape(user.nickname or '')}"
|
||||||
if user.club_card_number:
|
if user.club_card_number:
|
||||||
info += f"\n🎫 Клубная карта: <code>{user.club_card_number}</code>"
|
info += f"\n🎫 Клубная карта: <code>{escape(user.club_card_number or '')}</code>"
|
||||||
if user.phone:
|
if user.phone:
|
||||||
info += f"\n📞 Телефон: <code>{user.phone}</code>"
|
info += f"\n📞 Телефон: <code>{escape(user.phone or '')}</code>"
|
||||||
|
|
||||||
# Даты
|
# Даты
|
||||||
info += f"\n📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}"
|
info += f"\n📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}"
|
||||||
if user.last_activity:
|
if user.last_activity:
|
||||||
days_inactive = (datetime.now(timezone.utc) - user.last_activity).days
|
last_activity = user.last_activity
|
||||||
|
if last_activity.tzinfo is None:
|
||||||
|
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||||
|
days_inactive = (datetime.now(timezone.utc) - last_activity).days
|
||||||
info += f"\n⏰ Последняя активность: {user.last_activity.strftime('%d.%m.%Y %H:%M')}"
|
info += f"\n⏰ Последняя активность: {user.last_activity.strftime('%d.%m.%Y %H:%M')}"
|
||||||
if days_inactive > 0:
|
if days_inactive > 0:
|
||||||
info += f" ({days_inactive} дн. назад)"
|
info += f" ({days_inactive} дн. назад)"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async def conduct_lottery_draw(lottery_id: int):
|
|||||||
# Выводим список участников
|
# Выводим список участников
|
||||||
print("\n👥 Список участников:")
|
print("\n👥 Список участников:")
|
||||||
for i, user in enumerate(participants, 1):
|
for i, user in enumerate(participants, 1):
|
||||||
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов']
|
accounts = [p.account_number for p in lottery.participations if p.user_id == user.id and p.account_number]
|
||||||
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
||||||
|
|
||||||
# Проводим розыгрыш
|
# Проводим розыгрыш
|
||||||
@@ -48,14 +48,13 @@ async def conduct_lottery_draw(lottery_id: int):
|
|||||||
|
|
||||||
if winners:
|
if winners:
|
||||||
print(f"\n🎉 Победители определены:")
|
print(f"\n🎉 Победители определены:")
|
||||||
for i, winner_data in enumerate(winners, 1):
|
for i, winner_data in winners.items():
|
||||||
user = winner_data['user']
|
user = winner_data['user']
|
||||||
prize = winner_data['prize']
|
prize = winner_data['prize']
|
||||||
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
||||||
print(f" 💎 Приз: {prize}")
|
print(f" 💎 Приз: {prize}")
|
||||||
|
|
||||||
# Обновляем статус розыгрыша
|
# Обновляем статус розыгрыша
|
||||||
await LotteryService.set_lottery_completed(session, lottery_id, True)
|
|
||||||
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -99,4 +98,4 @@ async def main():
|
|||||||
print("❌ Неверный ID розыгрыша")
|
print("❌ Неверный ID розыгрыша")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from ..core.database import async_session_maker, init_db
|
from ..core.database import async_session_maker, init_db
|
||||||
from ..core.services import UserService, LotteryService
|
from ..core.services import UserService, LotteryService, ParticipationService
|
||||||
from ..utils.admin_utils import AdminUtils, ReportGenerator
|
from ..utils.admin_utils import AdminUtils, ReportGenerator
|
||||||
|
|
||||||
|
|
||||||
@@ -87,17 +87,17 @@ async def demo_admin_features():
|
|||||||
participants_added = 0
|
participants_added = 0
|
||||||
for user in users:
|
for user in users:
|
||||||
# В первый розыгрыш добавляем всех
|
# В первый розыгрыш добавляем всех
|
||||||
if await LotteryService.add_participant(session, lottery1.id, user.id):
|
if await ParticipationService.add_participant(session, lottery1.id, user.id):
|
||||||
participants_added += 1
|
participants_added += 1
|
||||||
|
|
||||||
# Во второй - половину
|
# Во второй - половину
|
||||||
if user.id % 2 == 0:
|
if user.id % 2 == 0:
|
||||||
if await LotteryService.add_participant(session, lottery2.id, user.id):
|
if await ParticipationService.add_participant(session, lottery2.id, user.id):
|
||||||
participants_added += 1
|
participants_added += 1
|
||||||
|
|
||||||
# В третий - треть
|
# В третий - треть
|
||||||
if user.id % 3 == 0:
|
if user.id % 3 == 0:
|
||||||
if await LotteryService.add_participant(session, lottery3.id, user.id):
|
if await ParticipationService.add_participant(session, lottery3.id, user.id):
|
||||||
participants_added += 1
|
participants_added += 1
|
||||||
|
|
||||||
print(f"✅ Добавлено {participants_added} участий")
|
print(f"✅ Добавлено {participants_added} участий")
|
||||||
@@ -188,4 +188,4 @@ async def demo_admin_features():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(demo_admin_features())
|
asyncio.run(demo_admin_features())
|
||||||
|
|||||||
@@ -33,15 +33,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
|
|||||||
|
|
||||||
elif display_type == 'account_number':
|
elif display_type == 'account_number':
|
||||||
# Отображаем номер клиентского счета
|
# Отображаем номер клиентского счета
|
||||||
if not user.account_number:
|
account_number = getattr(user, "account_number", None)
|
||||||
|
if not account_number:
|
||||||
return "Счёт не указан"
|
return "Счёт не указан"
|
||||||
|
|
||||||
if show_sensitive_data:
|
if show_sensitive_data:
|
||||||
# Для админов показываем полный номер
|
# Для админов показываем полный номер
|
||||||
return f"Счёт: {user.account_number}"
|
return f"Счёт: {account_number}"
|
||||||
else:
|
else:
|
||||||
# Для публичного показа маскируем номер
|
# Для публичного показа маскируем номер
|
||||||
masked = mask_account_number(user.account_number, show_last_digits=4)
|
masked = mask_account_number(account_number, show_last_digits=4)
|
||||||
return f"Счёт: {masked}"
|
return f"Счёт: {masked}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -113,4 +114,4 @@ def validate_display_type(display_type: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True если тип корректен
|
bool: True если тип корректен
|
||||||
"""
|
"""
|
||||||
return display_type in ['username', 'chat_id', 'account_number']
|
return display_type in ['username', 'chat_id', 'account_number']
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Обработчики для работы со счетами в розыгрышах
|
Обработчики для работы со счетами в розыгрышах
|
||||||
"""
|
"""
|
||||||
|
from src.core.access import is_admin
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import StateFilter
|
from aiogram.filters import StateFilter
|
||||||
@@ -28,9 +29,6 @@ class AccountStates(StatesGroup):
|
|||||||
account_router = Router()
|
account_router = Router()
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка прав администратора"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
@account_router.message(
|
@account_router.message(
|
||||||
@@ -44,7 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
Активируется только для администраторов
|
Активируется только для администраторов
|
||||||
Извлекает номер клубной карты и определяет владельца
|
Извлекает номер клубной карты и определяет владельца
|
||||||
"""
|
"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Парсим счета из сообщения
|
# Парсим счета из сообщения
|
||||||
@@ -156,7 +154,7 @@ async def cancel_account_action(callback: CallbackQuery, state: FSMContext):
|
|||||||
@account_router.callback_query(F.data == "account_action:add_to_lottery")
|
@account_router.callback_query(F.data == "account_action:add_to_lottery")
|
||||||
async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext):
|
async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Выбор розыгрыша для добавления счетов"""
|
"""Выбор розыгрыша для добавления счетов"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -200,7 +198,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
|
|||||||
@account_router.callback_query(F.data.startswith("add_accounts_to:"))
|
@account_router.callback_query(F.data.startswith("add_accounts_to:"))
|
||||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Добавление счетов в выбранный розыгрыш"""
|
"""Добавление счетов в выбранный розыгрыш"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -258,7 +256,7 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
@account_router.callback_query(F.data == "account_action:set_as_winner")
|
@account_router.callback_query(F.data == "account_action:set_as_winner")
|
||||||
async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Выбор розыгрыша для установки победителя"""
|
"""Выбор розыгрыша для установки победителя"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -319,7 +317,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
@account_router.callback_query(F.data.startswith("winner_lottery:"))
|
@account_router.callback_query(F.data.startswith("winner_lottery:"))
|
||||||
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Выбор места для победителя"""
|
"""Выбор места для победителя"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -382,7 +380,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
|||||||
@account_router.callback_query(F.data.startswith("winner_place:"))
|
@account_router.callback_query(F.data.startswith("winner_place:"))
|
||||||
async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Установка счета как победителя"""
|
"""Установка счета как победителя"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select, delete, func
|
|||||||
from ..core.models import Lottery, Participation, Winner
|
from ..core.models import Lottery, Participation, Winner
|
||||||
from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern
|
from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
|
from ..core.transactions import lock_open_lottery
|
||||||
|
|
||||||
|
|
||||||
class AccountParticipationService:
|
class AccountParticipationService:
|
||||||
@@ -49,6 +50,9 @@ class AccountParticipationService:
|
|||||||
"account_number": account_number
|
"account_number": account_number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Розыгрыш закрыт или не найден", "account_number": formatted_account}
|
||||||
# Проверяем существование розыгрыша
|
# Проверяем существование розыгрыша
|
||||||
lottery = await session.get(Lottery, lottery_id)
|
lottery = await session.get(Lottery, lottery_id)
|
||||||
if not lottery:
|
if not lottery:
|
||||||
@@ -66,6 +70,7 @@ class AccountParticipationService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
|
await session.rollback()
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -82,6 +87,12 @@ class AccountParticipationService:
|
|||||||
select(Account).where(Account.account_number == formatted_account)
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
)
|
)
|
||||||
account_record = account_record.scalar_one_or_none()
|
account_record = account_record.scalar_one_or_none()
|
||||||
|
if account_record and not account_record.is_active:
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Счет неактивен", "account_number": formatted_account}
|
||||||
|
if card_number and (not user or user.club_card_number != card_number):
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Карта не соответствует владельцу счета", "account_number": formatted_account}
|
||||||
|
|
||||||
# Добавляем участие с полными данными
|
# Добавляем участие с полными данными
|
||||||
participation = Participation(
|
participation = Participation(
|
||||||
@@ -149,6 +160,9 @@ class AccountParticipationService:
|
|||||||
"message": f"Неверный формат счета: {account_number}"
|
"message": f"Неверный формат счета: {account_number}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
|
||||||
participation = await session.execute(
|
participation = await session.execute(
|
||||||
select(Participation).where(
|
select(Participation).where(
|
||||||
Participation.lottery_id == lottery_id,
|
Participation.lottery_id == lottery_id,
|
||||||
@@ -158,6 +172,7 @@ class AccountParticipationService:
|
|||||||
participation = participation.scalar_one_or_none()
|
participation = participation.scalar_one_or_none()
|
||||||
|
|
||||||
if not participation:
|
if not participation:
|
||||||
|
await session.rollback()
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Счет {formatted_account} не участвует в розыгрыше"
|
"message": f"Счет {formatted_account} не участвует в розыгрыше"
|
||||||
@@ -236,6 +251,13 @@ class AccountParticipationService:
|
|||||||
Устанавливает счет как победителя в розыгрыше.
|
Устанавливает счет как победителя в розыгрыше.
|
||||||
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
"""
|
"""
|
||||||
|
if not await lock_open_lottery(session, lottery_id):
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
|
||||||
|
lottery = await session.get(Lottery, lottery_id)
|
||||||
|
if place < 1 or place > len(lottery.prizes or [None]):
|
||||||
|
await session.rollback()
|
||||||
|
return {"success": False, "message": "Некорректное призовое место"}
|
||||||
# Разделяем номер карты и счета, если они указаны вместе
|
# Разделяем номер карты и счета, если они указаны вместе
|
||||||
card_number = None
|
card_number = None
|
||||||
parts = account_number.split()
|
parts = account_number.split()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Админские обработчики для управления счетами и верификации"""
|
"""Админские обработчики для управления счетами и верификации"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -12,7 +13,7 @@ from src.core.registration_services import AccountService, WinnerNotificationSer
|
|||||||
from src.core.services import UserService, LotteryService, ParticipationService
|
from src.core.services import UserService, LotteryService, ParticipationService
|
||||||
from src.core.models import User, Winner, Account, Participation
|
from src.core.models import User, Winner, Account, Participation
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
from src.core.permissions import admin_only
|
from src.core.access import staff_only
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -24,7 +25,7 @@ class AddAccountStates(StatesGroup):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("cancel"))
|
@router.message(CaseInsensitiveCommand("cancel"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def cancel_command(message: Message, state: FSMContext):
|
async def cancel_command(message: Message, state: FSMContext):
|
||||||
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -32,7 +33,7 @@ async def cancel_command(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("add_account"))
|
@router.message(CaseInsensitiveCommand("add_account"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def add_account_command(message: Message, state: FSMContext):
|
async def add_account_command(message: Message, state: FSMContext):
|
||||||
"""
|
"""
|
||||||
Добавить счет пользователю по клубной карте (регистронезависимо)
|
Добавить счет пользователю по клубной карте (регистронезависимо)
|
||||||
@@ -107,7 +108,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
|
|||||||
)
|
)
|
||||||
text += "📨 Владельцу отправлено уведомление\n\n"
|
text += "📨 Владельцу отправлено уведомление\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
|
text += f"⚠️ Не удалось отправить уведомление: {public_error(e)}\n\n"
|
||||||
|
|
||||||
# Предлагаем добавить в розыгрыш
|
# Предлагаем добавить в розыгрыш
|
||||||
await show_lottery_selection(message, text, state)
|
await show_lottery_selection(message, text, state)
|
||||||
@@ -116,11 +117,11 @@ async def process_single_account(message: Message, club_card: str, account_numbe
|
|||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AddAccountStates.waiting_for_data)
|
@router.message(AddAccountStates.waiting_for_data, F.text)
|
||||||
async def process_accounts_data(message: Message, state: FSMContext):
|
async def process_accounts_data(message: Message, state: FSMContext):
|
||||||
"""Обработка данных счетов (один или несколько)"""
|
"""Обработка данных счетов (один или несколько)"""
|
||||||
if message.text.strip().lower() == '/cancel':
|
if message.text.strip().lower() == '/cancel':
|
||||||
@@ -220,7 +221,7 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
|
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Счет {account_number}: {str(e)}")
|
errors.append(f"Счет {account_number}: {public_error(e)}")
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
@@ -383,36 +384,10 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
for acc in accounts:
|
from .account_services import AccountParticipationService
|
||||||
try:
|
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts])
|
||||||
# Добавляем участие через account_id
|
success_count = result['added']
|
||||||
# Проверяем, не участвует ли уже
|
errors = result['errors']
|
||||||
existing = await session.execute(
|
|
||||||
select(Participation).where(
|
|
||||||
and_(
|
|
||||||
Participation.lottery_id == lottery_id,
|
|
||||||
Participation.account_id == acc['account_id']
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
errors.append(f"{acc['account_number']}: уже участвует")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Создаем участие
|
|
||||||
participation = Participation(
|
|
||||||
lottery_id=lottery_id,
|
|
||||||
account_id=acc['account_id'],
|
|
||||||
account_number=acc['account_number']
|
|
||||||
)
|
|
||||||
session.add(participation)
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors.append(f"{acc['account_number']}: {str(e)}")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
||||||
|
|
||||||
@@ -436,7 +411,7 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("remove_account"))
|
@router.message(CaseInsensitiveCommand("remove_account"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def remove_account_command(message: Message):
|
async def remove_account_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Деактивировать счет(а) (регистронезависимо)
|
Деактивировать счет(а) (регистронезависимо)
|
||||||
@@ -502,11 +477,11 @@ async def remove_account_command(message: Message):
|
|||||||
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
|
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
await message.answer(f"❌ Критическая ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("verify_winner"))
|
@router.message(CaseInsensitiveCommand("verify_winner"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def verify_winner_command(message: Message):
|
async def verify_winner_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
||||||
@@ -585,7 +560,7 @@ async def verify_winner_command(message: Message):
|
|||||||
)
|
)
|
||||||
text += "\n📨 Победителю отправлено уведомление"
|
text += "\n📨 Победителю отправлено уведомление"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
|
text += f"\n⚠️ Не удалось отправить уведомление: {public_error(e)}"
|
||||||
|
|
||||||
if winner.account_number:
|
if winner.account_number:
|
||||||
text += f"💳 Счет: {winner.account_number}\n"
|
text += f"💳 Счет: {winner.account_number}\n"
|
||||||
@@ -593,11 +568,11 @@ async def verify_winner_command(message: Message):
|
|||||||
await message.answer(text)
|
await message.answer(text)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("winner_status"))
|
@router.message(CaseInsensitiveCommand("winner_status"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def winner_status_command(message: Message):
|
async def winner_status_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать статус всех победителей розыгрыша (регистронезависимо)
|
Показать статус всех победителей розыгрыша (регистронезависимо)
|
||||||
@@ -666,11 +641,11 @@ async def winner_status_command(message: Message):
|
|||||||
await message.answer(text)
|
await message.answer(text)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("user_info"))
|
@router.message(CaseInsensitiveCommand("user_info"))
|
||||||
@admin_only
|
@staff_only
|
||||||
async def user_info_command(message: Message):
|
async def user_info_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать информацию о пользователе (регистронезависимо)
|
Показать информацию о пользователе (регистронезависимо)
|
||||||
@@ -741,7 +716,7 @@ async def user_info_command(message: Message):
|
|||||||
await message.answer(text)
|
await message.answer(text)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "view_my_accounts")
|
@router.callback_query(F.data == "view_my_accounts")
|
||||||
@@ -807,6 +782,6 @@ async def view_my_accounts_callback(callback: CallbackQuery):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Не используем callback.answer в except - может быть timeout
|
# Не используем callback.answer в except - может быть timeout
|
||||||
try:
|
try:
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
except:
|
except:
|
||||||
pass # Игнорируем если не получилось отправить
|
pass # Игнорируем если не получилось отправить
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Админские обработчики для управления чатом"""
|
"""Админские обработчики для управления чатом"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -290,12 +291,9 @@ async def cmd_delete_message(message: Message):
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from src.core.models import ChatMessage
|
from src.core.models import ChatMessage
|
||||||
|
|
||||||
result = await session.execute(
|
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||||
select(ChatMessage).where(
|
session, message.reply_to_message.message_id, chat_id=message.chat.id,
|
||||||
ChatMessage.telegram_message_id == message.reply_to_message.message_id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
chat_message = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not chat_message:
|
if not chat_message:
|
||||||
await message.answer("❌ Сообщение не найдено в базе данных")
|
await message.answer("❌ Сообщение не найдено в базе данных")
|
||||||
@@ -317,7 +315,7 @@ async def cmd_delete_message(message: Message):
|
|||||||
await message.bot.delete_message(int(user_telegram_id), msg_id)
|
await message.bot.delete_message(int(user_telegram_id), msg_id)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}")
|
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {public_error(e)}")
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ <b>Сообщение удалено</b>\n\n"
|
f"✅ <b>Сообщение удалено</b>\n\n"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
Хендлеры для управления кастомными эмодзи админом
|
Хендлеры для управления кастомными эмодзи админом
|
||||||
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||||
"""
|
"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
import logging
|
import logging
|
||||||
|
from html import escape
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
@@ -11,6 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.database import async_session_maker
|
from ..core.database import async_session_maker
|
||||||
|
from ..core.access import is_admin
|
||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
from ..core.emoji_mapping_service import EmojiMappingService
|
from ..core.emoji_mapping_service import EmojiMappingService
|
||||||
|
|
||||||
@@ -26,73 +29,36 @@ class EmojiStates(StatesGroup):
|
|||||||
@router.message(Command("add_emoji"), StateFilter(None))
|
@router.message(Command("add_emoji"), StateFilter(None))
|
||||||
async def add_emoji_start(message: Message, state: FSMContext):
|
async def add_emoji_start(message: Message, state: FSMContext):
|
||||||
"""Начать процесс добавления нового эмодзи"""
|
"""Начать процесс добавления нового эмодзи"""
|
||||||
if message.from_user.id not in ADMIN_IDS:
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
|
"🎨 Отправьте один премиум-эмодзи из панели Telegram.\n\n"
|
||||||
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
|
"Бот сохранит его в каталоге для использования в шаблонах. "
|
||||||
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
|
"В чатах и рассылках оформление сохраняется прямо из отправленного сообщения.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
await state.set_state(EmojiStates.waiting_for_emoji)
|
await state.set_state(EmojiStates.waiting_for_emoji)
|
||||||
|
|
||||||
|
|
||||||
@router.message(EmojiStates.waiting_for_emoji)
|
@router.message(EmojiStates.waiting_for_emoji, F.text)
|
||||||
async def receive_emoji(message: Message, state: FSMContext):
|
async def receive_emoji(message: Message, state: FSMContext):
|
||||||
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
||||||
# Проверяем что это именно тект сообщение с эмодзи
|
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
|
||||||
if not message.text or len(message.text) > 10:
|
if len(entities) != 1:
|
||||||
await message.answer(
|
await message.answer("Отправьте один премиум-эмодзи из панели эмодзи Telegram. Обычный символ не содержит его ID.")
|
||||||
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
entity = entities[0]
|
||||||
emoji_text = message.text.strip()
|
emoji_text = entity.extract_from(message.text)
|
||||||
|
emoji_id = entity.custom_emoji_id
|
||||||
# Проверяем что хотя бы один символ это эмодзи
|
if message.text.strip() != emoji_text or len(emoji_text) > 10 or not emoji_id or not emoji_id.isascii() or not emoji_id.isdigit():
|
||||||
has_emoji = any(ord(c) > 127 for c in emoji_text)
|
await message.answer("Отправьте только один премиум-эмодзи, без дополнительного текста.")
|
||||||
if not has_emoji:
|
|
||||||
await message.answer(
|
|
||||||
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id)
|
||||||
# Извлекаем emoji_id из entities если это есть
|
|
||||||
emoji_id = None
|
|
||||||
|
|
||||||
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
|
|
||||||
if message.entities:
|
|
||||||
for entity in message.entities:
|
|
||||||
if entity.type == "custom_emoji":
|
|
||||||
# Получаем text с этим entity
|
|
||||||
emoji_id = entity.custom_emoji_id
|
|
||||||
break
|
|
||||||
|
|
||||||
# Если нет custom_emoji entity, пробуем другой способ
|
|
||||||
if not emoji_id:
|
|
||||||
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
|
|
||||||
# и смотрим entities
|
|
||||||
try:
|
|
||||||
# Отправляем сообщение с эмодзи обратно
|
|
||||||
test_msg = await message.answer(
|
|
||||||
f"Тестирую эмодзи: {emoji_text}",
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
# Пытаемся получить emoji_id из реакции
|
|
||||||
# В Telegram для premium emoji нужно обращаться к API
|
|
||||||
# Но мы можем просто использовать сам emoji как ID - он уникален
|
|
||||||
emoji_id = emoji_text
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error testing emoji: {e}")
|
|
||||||
emoji_id = emoji_text
|
|
||||||
|
|
||||||
# Сохраняем в состояние
|
|
||||||
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
|
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
|
f"✅ Получил эмодзи: <code>{escape(emoji_text or '')}</code>\n\n"
|
||||||
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
||||||
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
@@ -100,7 +66,7 @@ async def receive_emoji(message: Message, state: FSMContext):
|
|||||||
await state.set_state(EmojiStates.waiting_for_description)
|
await state.set_state(EmojiStates.waiting_for_description)
|
||||||
|
|
||||||
|
|
||||||
@router.message(EmojiStates.waiting_for_description)
|
@router.message(EmojiStates.waiting_for_description, F.text)
|
||||||
async def receive_emoji_description(message: Message, state: FSMContext):
|
async def receive_emoji_description(message: Message, state: FSMContext):
|
||||||
"""Получить описание эмодзи и сохранить в БД"""
|
"""Получить описание эмодзи и сохранить в БД"""
|
||||||
if not message.text:
|
if not message.text:
|
||||||
@@ -108,10 +74,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
description = message.text.strip()
|
description = message.text.strip()
|
||||||
|
if not description or len(description) > 255:
|
||||||
|
await message.answer("Введите описание длиной от 1 до 255 символов.")
|
||||||
|
return
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
emoji_text = data.get("emoji_text")
|
emoji_text = data.get("emoji_text")
|
||||||
emoji_id = data.get("emoji_id")
|
emoji_id = data.get("emoji_id")
|
||||||
|
|
||||||
|
if not emoji_text or not emoji_id:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("Начните добавление заново: /add_emoji")
|
||||||
|
return
|
||||||
|
|
||||||
# Сохраняем в БД
|
# Сохраняем в БД
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
emoji_service = EmojiMappingService(session)
|
emoji_service = EmojiMappingService(session)
|
||||||
@@ -120,8 +94,8 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
|||||||
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
|
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
|
||||||
if existing:
|
if existing:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
|
f"⚠️ Вы уже зарегистрировали этот эмодзи: {escape(emoji_text or '')}\n"
|
||||||
f"Описание: <code>{existing.description}</code>",
|
f"Описание: <code>{escape(existing.description or '')}</code>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -137,16 +111,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
|
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
|
||||||
f"Эмодзи: <code>{emoji_text}</code>\n"
|
f"Эмодзи: <code>{escape(emoji_text or '')}</code>\n"
|
||||||
f"Описание: <code>{description}</code>\n"
|
f"Описание: <code>{escape(description or '')}</code>\n"
|
||||||
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
||||||
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
|
"Эмодзи сохранён в каталоге. В чатах и рассылках отправляйте его из панели Telegram.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error), parse_mode=None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error registering emoji: {e}")
|
logger.error(f"Error registering emoji: {e}")
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
|
f"❌ Ошибка при сохранении эмодзи: {public_error(e)}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,7 +132,7 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
|||||||
@router.message(Command("my_emojis"))
|
@router.message(Command("my_emojis"))
|
||||||
async def list_my_emojis(message: Message):
|
async def list_my_emojis(message: Message):
|
||||||
"""Показать все эмодзи, добавленные этим админом"""
|
"""Показать все эмодзи, добавленные этим админом"""
|
||||||
if message.from_user.id not in ADMIN_IDS:
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -174,7 +150,7 @@ async def list_my_emojis(message: Message):
|
|||||||
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
|
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
|
||||||
for emoji in emojis:
|
for emoji in emojis:
|
||||||
text += (
|
text += (
|
||||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
||||||
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
|
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
|
||||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||||
)
|
)
|
||||||
@@ -185,7 +161,7 @@ async def list_my_emojis(message: Message):
|
|||||||
@router.message(Command("all_emojis"))
|
@router.message(Command("all_emojis"))
|
||||||
async def list_all_emojis(message: Message):
|
async def list_all_emojis(message: Message):
|
||||||
"""Показать все зарегистрированные эмодзи (для всех админов)"""
|
"""Показать все зарегистрированные эмодзи (для всех админов)"""
|
||||||
if message.from_user.id not in ADMIN_IDS:
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -202,8 +178,8 @@ async def list_all_emojis(message: Message):
|
|||||||
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
|
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
|
||||||
for emoji in emojis:
|
for emoji in emojis:
|
||||||
text += (
|
text += (
|
||||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
||||||
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
|
f" Админ: <code>{escape(emoji.admin.first_name or 'Администратор') if emoji.admin else 'Администратор'}</code> "
|
||||||
f"(ID: {emoji.admin_id})\n"
|
f"(ID: {emoji.admin_id})\n"
|
||||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||||
)
|
)
|
||||||
@@ -214,7 +190,7 @@ async def list_all_emojis(message: Message):
|
|||||||
@router.message(Command("delete_emoji"))
|
@router.message(Command("delete_emoji"))
|
||||||
async def delete_emoji_start(message: Message, state: FSMContext):
|
async def delete_emoji_start(message: Message, state: FSMContext):
|
||||||
"""Удалить эмодзи"""
|
"""Удалить эмодзи"""
|
||||||
if message.from_user.id not in ADMIN_IDS:
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -233,7 +209,7 @@ async def delete_emoji_start(message: Message, state: FSMContext):
|
|||||||
for emoji in emojis:
|
for emoji in emojis:
|
||||||
buttons.append([
|
buttons.append([
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=f"{emoji.emoji_text} ({emoji.description})",
|
text=f"{emoji.emoji_text} ({emoji.description or ''})",
|
||||||
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
@@ -258,14 +234,14 @@ async def delete_emoji_confirm(callback: CallbackQuery):
|
|||||||
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
|
if (not emoji.admin or emoji.admin.telegram_id != callback.from_user.id) and callback.from_user.id not in ADMIN_IDS:
|
||||||
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
|
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
success = await emoji_service.delete_emoji(emoji_id)
|
success = await emoji_service.delete_emoji(emoji_id)
|
||||||
if success:
|
if success:
|
||||||
await callback.answer(
|
await callback.answer(
|
||||||
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
|
f"✅ Эмодзи {emoji.emoji_text} удалено",
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
await callback.message.delete()
|
await callback.message.delete()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Расширенная админ-панель для управления розыгрышами
|
Расширенная админ-панель для управления розыгрышами
|
||||||
"""
|
"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
|
from src.core.access import is_admin
|
||||||
|
from src.core.access import is_admin as check_admin_access
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from datetime import timezone
|
||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
@@ -112,9 +117,6 @@ class AdminStates(StatesGroup):
|
|||||||
admin_router = Router()
|
admin_router = Router()
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка прав администратора (быстрая проверка только .env)"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
def is_super_admin(user_id: int) -> bool:
|
def is_super_admin(user_id: int) -> bool:
|
||||||
@@ -122,23 +124,6 @@ def is_super_admin(user_id: int) -> bool:
|
|||||||
return user_id in ADMIN_IDS
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
async def check_admin_access(user_id: int) -> bool:
|
|
||||||
"""
|
|
||||||
Асинхронная проверка доступа администратора.
|
|
||||||
Проверяет как главных администраторов (.env), так и назначенных (БД)
|
|
||||||
"""
|
|
||||||
# Сначала проверяем главных администраторов
|
|
||||||
if user_id in ADMIN_IDS:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Затем проверяем назначенных администраторов в БД
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
from sqlalchemy import select
|
|
||||||
result = await session.execute(
|
|
||||||
select(User).where(User.telegram_id == user_id, User.is_admin == True)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
return user is not None
|
|
||||||
|
|
||||||
|
|
||||||
def get_admin_main_keyboard() -> InlineKeyboardMarkup:
|
def get_admin_main_keyboard() -> InlineKeyboardMarkup:
|
||||||
@@ -233,7 +218,7 @@ async def show_admin_panel(callback: CallbackQuery):
|
|||||||
# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ
|
# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ
|
||||||
# ======================
|
# ======================
|
||||||
|
|
||||||
@admin_router.callback_query(F.data == "admin_lotteries")
|
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
|
||||||
async def show_lottery_management(callback: CallbackQuery):
|
async def show_lottery_management(callback: CallbackQuery):
|
||||||
"""Управление розыгрышами"""
|
"""Управление розыгрышами"""
|
||||||
if not await check_admin_access(callback.from_user.id):
|
if not await check_admin_access(callback.from_user.id):
|
||||||
@@ -277,7 +262,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
|
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
|
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.lottery_title))
|
@admin_router.message(StateFilter(AdminStates.lottery_title))
|
||||||
@@ -784,7 +769,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.add_participant_user)
|
await state.set_state(AdminStates.add_participant_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_participant_user))
|
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
|
||||||
async def process_add_participant(message: Message, state: FSMContext):
|
async def process_add_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка добавления участника"""
|
"""Обработка добавления участника"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -905,7 +890,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user))
|
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
|
||||||
async def process_remove_participant(message: Message, state: FSMContext):
|
async def process_remove_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка удаления участника"""
|
"""Обработка удаления участника"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1180,7 +1165,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.participant_search)
|
await state.set_state(AdminStates.participant_search)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.participant_search))
|
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
|
||||||
async def process_search_participants(message: Message, state: FSMContext):
|
async def process_search_participants(message: Message, state: FSMContext):
|
||||||
"""Обработка поиска участников"""
|
"""Обработка поиска участников"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1490,7 +1475,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
|
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
|
||||||
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
|
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Добавление участника в конкретный розыгрыш"""
|
"""Добавление участника в конкретный розыгрыш"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1520,10 +1505,10 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
|
|||||||
await state.set_state(AdminStates.add_to_lottery_user)
|
await state.set_state(AdminStates.add_to_lottery_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
|
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
|
||||||
async def process_add_to_lottery(message: Message, state: FSMContext):
|
async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||||
"""Обработка добавления участника в конкретный розыгрыш"""
|
"""Обработка добавления участника в конкретный розыгрыш"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1624,7 +1609,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
|
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
|
||||||
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
|
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Удаление участника из конкретного розыгрыша"""
|
"""Удаление участника из конкретного розыгрыша"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1656,10 +1641,10 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
|
|||||||
await state.set_state(AdminStates.remove_from_lottery_user)
|
await state.set_state(AdminStates.remove_from_lottery_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
|
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
|
||||||
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||||
"""Обработка удаления участника из конкретного розыгрыша"""
|
"""Обработка удаления участника из конкретного розыгрыша"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1741,7 +1726,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
|
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
|
||||||
async def check_winners(callback: CallbackQuery):
|
async def check_winners(callback: CallbackQuery):
|
||||||
"""Проверка подтверждения победителей"""
|
"""Проверка подтверждения победителей"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1810,7 +1795,7 @@ async def check_winners(callback: CallbackQuery):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
|
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
|
||||||
async def redraw_lottery(callback: CallbackQuery):
|
async def redraw_lottery(callback: CallbackQuery):
|
||||||
"""Повторный розыгрыш для неподтверждённых призов"""
|
"""Повторный розыгрыш для неподтверждённых призов"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1860,123 +1845,23 @@ async def redraw_lottery(callback: CallbackQuery):
|
|||||||
|
|
||||||
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
|
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
|
||||||
async def confirm_redraw(callback: CallbackQuery):
|
async def confirm_redraw(callback: CallbackQuery):
|
||||||
"""Подтверждение и выполнение повторного розыгрыша"""
|
if not await check_admin_access(callback.from_user.id):
|
||||||
if not is_admin(callback.from_user.id):
|
await callback.answer("Недостаточно прав", show_alert=True)
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
|
||||||
return
|
return
|
||||||
|
from src.core.redraw_services import redraw_unclaimed
|
||||||
|
from src.utils.notifications import notify_winners_async
|
||||||
lottery_id = int(callback.data.split("_")[-1])
|
lottery_id = int(callback.data.split("_")[-1])
|
||||||
|
await callback.answer("Проверяю неподтверждённые призы")
|
||||||
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
from sqlalchemy import select, delete
|
winners = await redraw_unclaimed(session, lottery_id)
|
||||||
from ..core.models import Winner, Participation
|
if winners:
|
||||||
import random
|
|
||||||
|
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
|
||||||
if not lottery:
|
|
||||||
await callback.message.edit_text("❌ Розыгрыш не найден")
|
|
||||||
return
|
|
||||||
|
|
||||||
winners = await LotteryService.get_winners(session, lottery_id)
|
|
||||||
|
|
||||||
# Собираем подтверждённые и неподтверждённые
|
|
||||||
confirmed_winners = [w for w in winners if w.is_claimed]
|
|
||||||
unconfirmed_winners = [w for w in winners if not w.is_claimed]
|
|
||||||
|
|
||||||
if not unconfirmed_winners:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"✅ Все победители уже подтверждены!",
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
|
|
||||||
excluded_accounts = set()
|
|
||||||
for w in winners:
|
|
||||||
if w.account_number:
|
|
||||||
excluded_accounts.add(w.account_number)
|
|
||||||
|
|
||||||
# Получаем всех участников, исключая уже выигравших
|
|
||||||
result = await session.execute(
|
|
||||||
select(Participation)
|
|
||||||
.where(Participation.lottery_id == lottery_id)
|
|
||||||
)
|
|
||||||
all_participations = result.scalars().all()
|
|
||||||
|
|
||||||
# Фильтруем участников
|
|
||||||
available_participations = [
|
|
||||||
p for p in all_participations
|
|
||||||
if p.account_number not in excluded_accounts
|
|
||||||
]
|
|
||||||
|
|
||||||
if not available_participations:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"❌ Нет доступных участников для переигровки.\n"
|
|
||||||
"Все участники уже выиграли или были исключены.",
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Удаляем неподтверждённых победителей
|
|
||||||
for winner in unconfirmed_winners:
|
|
||||||
await session.delete(winner)
|
|
||||||
|
|
||||||
# Проводим розыгрыш для неподтверждённых мест
|
|
||||||
new_winners_text = ""
|
|
||||||
random.shuffle(available_participations)
|
|
||||||
|
|
||||||
for i, old_winner in enumerate(unconfirmed_winners):
|
|
||||||
if i >= len(available_participations):
|
|
||||||
break
|
|
||||||
|
|
||||||
new_participation = available_participations[i]
|
|
||||||
|
|
||||||
# Создаём нового победителя
|
|
||||||
new_winner = Winner(
|
|
||||||
lottery_id=lottery_id,
|
|
||||||
user_id=new_participation.user_id,
|
|
||||||
account_number=new_participation.account_number,
|
|
||||||
place=old_winner.place,
|
|
||||||
is_manual=False,
|
|
||||||
is_claimed=False
|
|
||||||
)
|
|
||||||
session.add(new_winner)
|
|
||||||
|
|
||||||
# Исключаем из следующих итераций
|
|
||||||
if new_participation.account_number:
|
|
||||||
excluded_accounts.add(new_participation.account_number)
|
|
||||||
|
|
||||||
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
|
|
||||||
name = new_participation.account_number or f"ID: {new_participation.user_id}"
|
|
||||||
new_winners_text += f"🏆 {old_winner.place} место: {name} → {prize}\n"
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Отправляем уведомления новым победителям
|
|
||||||
from ..utils.notifications import notify_winners_async
|
|
||||||
try:
|
|
||||||
await notify_winners_async(callback.bot, session, lottery_id)
|
await notify_winners_async(callback.bot, session, lottery_id)
|
||||||
except Exception as e:
|
text = "Повторный розыгрыш завершён.\n" if winners else "Нет просроченных неподтверждённых призов или новых участников."
|
||||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
for winner in winners:
|
||||||
|
text += f"{winner.place} место: {winner.account_number or winner.user_id} — {winner.prize}\n"
|
||||||
|
await callback.message.answer(text)
|
||||||
|
|
||||||
text = f"🎉 Повторный розыгрыш завершён!\n\n"
|
|
||||||
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
|
|
||||||
text += f"Новые победители:\n{new_winners_text}\n"
|
|
||||||
text += "✅ Уведомления отправлены новым победителям"
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
|
|
||||||
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
@@ -1986,7 +1871,7 @@ async def confirm_redraw(callback: CallbackQuery):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
|
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
|
||||||
async def delete_lottery_confirm(callback: CallbackQuery):
|
async def delete_lottery_confirm(callback: CallbackQuery):
|
||||||
"""Подтверждение удаления розыгрыша"""
|
"""Подтверждение удаления розыгрыша"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2020,7 +1905,7 @@ async def delete_lottery_confirm(callback: CallbackQuery):
|
|||||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
|
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
|
||||||
async def delete_lottery_execute(callback: CallbackQuery):
|
async def delete_lottery_execute(callback: CallbackQuery):
|
||||||
"""Выполнение удаления розыгрыша"""
|
"""Выполнение удаления розыгрыша"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2037,15 +1922,7 @@ async def delete_lottery_execute(callback: CallbackQuery):
|
|||||||
|
|
||||||
lottery_title = lottery.title
|
lottery_title = lottery.title
|
||||||
|
|
||||||
# Удаляем победителей
|
await LotteryService.delete_lottery(session, lottery_id)
|
||||||
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
|
|
||||||
|
|
||||||
# Удаляем участников
|
|
||||||
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
|
|
||||||
|
|
||||||
# Удаляем розыгрыш
|
|
||||||
await session.delete(lottery)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"✅ Розыгрыш удалён\n\n"
|
f"✅ Розыгрыш удалён\n\n"
|
||||||
@@ -2384,7 +2261,7 @@ async def show_participants_report(callback: CallbackQuery):
|
|||||||
select(
|
select(
|
||||||
User.first_name,
|
User.first_name,
|
||||||
User.username,
|
User.username,
|
||||||
User.account_number,
|
func.min(Participation.account_number),
|
||||||
func.count(Participation.id).label('participations')
|
func.count(Participation.id).label('participations')
|
||||||
)
|
)
|
||||||
.join(Participation)
|
.join(Participation)
|
||||||
@@ -2396,11 +2273,11 @@ async def show_participants_report(callback: CallbackQuery):
|
|||||||
|
|
||||||
# Участники с аккаунтами vs без
|
# Участники с аккаунтами vs без
|
||||||
users_with_accounts = await session.scalar(
|
users_with_accounts = await session.scalar(
|
||||||
select(func.count(User.id)).where(User.account_number.isnot(None))
|
select(func.count(User.id)).where(User.accounts.any())
|
||||||
)
|
)
|
||||||
|
|
||||||
users_without_accounts = await session.scalar(
|
users_without_accounts = await session.scalar(
|
||||||
select(func.count(User.id)).where(User.account_number.is_(None))
|
select(func.count(User.id)).where(~User.accounts.any())
|
||||||
)
|
)
|
||||||
|
|
||||||
text = "📈 Отчет по участникам\n\n"
|
text = "📈 Отчет по участникам\n\n"
|
||||||
@@ -2951,7 +2828,7 @@ async def process_winner_place(message: Message, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.set_winner_user)
|
await state.set_state(AdminStates.set_winner_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.set_winner_user))
|
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
|
||||||
async def process_winner_user(message: Message, state: FSMContext):
|
async def process_winner_user(message: Message, state: FSMContext):
|
||||||
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -3391,6 +3268,8 @@ async def do_remove_winner(callback: CallbackQuery):
|
|||||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||||
|
|
||||||
# Удаляем победителя
|
# Удаляем победителя
|
||||||
|
from src.core.models import WinnerVerification
|
||||||
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == winner_id))
|
||||||
await session.execute(delete(Winner).where(Winner.id == winner_id))
|
await session.execute(delete(Winner).where(Winner.id == winner_id))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -3443,7 +3322,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_draws")])
|
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||||
|
|
||||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||||
|
|
||||||
@@ -3546,7 +3425,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
|
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
|
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if winners_dict:
|
if winners_dict:
|
||||||
@@ -3587,7 +3466,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
|||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_draws")]
|
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_lotteries")]
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -4095,7 +3974,7 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"💥 Исключение при смене типа отображения: {e}")
|
logger.error(f"💥 Исключение при смене типа отображения: {e}")
|
||||||
text = f"❌ Ошибка: {str(e)}"
|
text = f"❌ Ошибка: {public_error(e)}"
|
||||||
await callback.answer("❌ Ошибка при сохранении!", show_alert=True)
|
await callback.answer("❌ Ошибка при сохранении!", show_alert=True)
|
||||||
return
|
return
|
||||||
await callback.answer("❌ Ошибка сохранения", show_alert=True)
|
await callback.answer("❌ Ошибка сохранения", show_alert=True)
|
||||||
@@ -4402,6 +4281,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
winners_dict: Словарь с победителями {место: данные}
|
winners_dict: Словарь с победителями {место: данные}
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from html import escape
|
||||||
|
|
||||||
# Получаем розыгрыш
|
# Получаем розыгрыш
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
@@ -4428,7 +4308,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
# Формируем сообщение с результатами
|
# Формируем сообщение с результатами
|
||||||
message = (
|
message = (
|
||||||
f"📢 <b>Результаты розыгрыша</b>\n\n"
|
f"📢 <b>Результаты розыгрыша</b>\n\n"
|
||||||
f"🎯 <b>{lottery.title}</b>\n\n"
|
f"🎯 <b>{escape(lottery.title)}</b>\n\n"
|
||||||
f"🏆 <b>Победители:</b>\n"
|
f"🏆 <b>Победители:</b>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4451,7 +4331,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
|
|
||||||
# Формируем строку победителя
|
# Формируем строку победителя
|
||||||
winner_name = nickname if nickname else display_name if display_name else f"Счет {winner.account_number}"
|
winner_name = nickname if nickname else display_name if display_name else f"Счет {winner.account_number}"
|
||||||
message += f"{winner.place} место: {winner_name}\n"
|
message += f"{winner.place} место: {escape(winner_name)}\n"
|
||||||
|
|
||||||
message += (
|
message += (
|
||||||
f"\n🎁 Поздравляем победителей!\n"
|
f"\n🎁 Поздравляем победителей!\n"
|
||||||
@@ -4459,6 +4339,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Рассылаем всем кроме победителей
|
# Рассылаем всем кроме победителей
|
||||||
|
await session.commit()
|
||||||
|
from src.utils.delivery import send_background
|
||||||
success_count = 0
|
success_count = 0
|
||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
|
||||||
@@ -4468,9 +4350,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await send_background(bot, chat_id=user.telegram_id,
|
||||||
user.telegram_id,
|
text=message,
|
||||||
message,
|
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
@@ -4488,100 +4369,19 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
|||||||
|
|
||||||
@admin_router.callback_query(F.data == "admin_export_users")
|
@admin_router.callback_query(F.data == "admin_export_users")
|
||||||
async def admin_export_users(callback: CallbackQuery):
|
async def admin_export_users(callback: CallbackQuery):
|
||||||
"""Экспорт всех пользователей в XLSX"""
|
|
||||||
if not await check_admin_access(callback.from_user.id):
|
if not await check_admin_access(callback.from_user.id):
|
||||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
await callback.answer("Готовлю файл")
|
||||||
|
import asyncio
|
||||||
|
from aiogram.types import BufferedInputFile
|
||||||
|
from src.utils.spreadsheets import export_users
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
users = await UserService.get_all_users(session)
|
||||||
|
content = await asyncio.to_thread(export_users, users)
|
||||||
|
await callback.message.answer_document(BufferedInputFile(content, filename="users.xlsx"),
|
||||||
|
caption=f"Пользователей: {len(users)}")
|
||||||
|
|
||||||
await callback.answer("⏳ Формирую файл...", show_alert=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
|
||||||
from io import BytesIO
|
|
||||||
from aiogram.types import BufferedInputFile
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
# Получаем всех пользователей
|
|
||||||
all_users = await UserService.get_all_users(session)
|
|
||||||
|
|
||||||
# Создаем Excel файл
|
|
||||||
wb = Workbook()
|
|
||||||
ws = wb.active
|
|
||||||
ws.title = "Пользователи"
|
|
||||||
|
|
||||||
# Заголовки
|
|
||||||
headers = [
|
|
||||||
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
|
|
||||||
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
|
|
||||||
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Стиль для заголовков
|
|
||||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
||||||
header_font = Font(bold=True, color="FFFFFF")
|
|
||||||
|
|
||||||
for col_num, header in enumerate(headers, 1):
|
|
||||||
cell = ws.cell(row=1, column=col_num, value=header)
|
|
||||||
cell.fill = header_fill
|
|
||||||
cell.font = header_font
|
|
||||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
||||||
|
|
||||||
# Данные пользователей
|
|
||||||
for row_num, user in enumerate(all_users, 2):
|
|
||||||
ws.cell(row=row_num, column=1, value=user.telegram_id)
|
|
||||||
ws.cell(row=row_num, column=2, value=user.username or '')
|
|
||||||
ws.cell(row=row_num, column=3, value=user.first_name or '')
|
|
||||||
ws.cell(row=row_num, column=4, value=user.last_name or '')
|
|
||||||
ws.cell(row=row_num, column=5, value=user.nickname or '')
|
|
||||||
ws.cell(row=row_num, column=6, value=user.phone or '')
|
|
||||||
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
|
|
||||||
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
|
|
||||||
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
|
|
||||||
ws.cell(row=row_num, column=10, value=user.verification_code or '')
|
|
||||||
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
|
|
||||||
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
|
|
||||||
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
|
|
||||||
|
|
||||||
# Автоподбор ширины колонок
|
|
||||||
for column in ws.columns:
|
|
||||||
max_length = 0
|
|
||||||
column_letter = column[0].column_letter
|
|
||||||
for cell in column:
|
|
||||||
try:
|
|
||||||
if len(str(cell.value)) > max_length:
|
|
||||||
max_length = len(str(cell.value))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
adjusted_width = min(max_length + 2, 50)
|
|
||||||
ws.column_dimensions[column_letter].width = adjusted_width
|
|
||||||
|
|
||||||
# Сохраняем в BytesIO
|
|
||||||
excel_file = BytesIO()
|
|
||||||
wb.save(excel_file)
|
|
||||||
excel_file.seek(0)
|
|
||||||
|
|
||||||
# Отправляем файл
|
|
||||||
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
||||||
file = BufferedInputFile(excel_file.read(), filename=filename)
|
|
||||||
|
|
||||||
registered_count = len([u for u in all_users if u.is_registered])
|
|
||||||
|
|
||||||
await callback.message.answer_document(
|
|
||||||
document=file,
|
|
||||||
caption=(
|
|
||||||
f"📥 <b>Экспорт пользователей</b>\n\n"
|
|
||||||
f"📊 Всего пользователей: {len(all_users)}\n"
|
|
||||||
f"✅ Зарегистрировано: {registered_count}\n"
|
|
||||||
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
|
||||||
),
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer("✅ Файл отправлен", show_alert=False)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка экспорта пользователей: {e}")
|
|
||||||
await callback.answer("❌ Ошибка при создании файла", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.callback_query(F.data == "admin_import_users")
|
@admin_router.callback_query(F.data == "admin_import_users")
|
||||||
@@ -4619,172 +4419,32 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
|
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
|
||||||
async def admin_import_users_process(message: Message, state: FSMContext):
|
async def admin_import_users_process(message: Message, state: FSMContext):
|
||||||
"""Обработка импорта пользователей из XLSX"""
|
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
return
|
return
|
||||||
|
import asyncio
|
||||||
# Проверяем формат файла
|
from src.utils.spreadsheets import read_users_xlsx, MAX_FILE_BYTES
|
||||||
if not message.document.file_name.endswith('.xlsx'):
|
from src.core.import_services import import_users
|
||||||
await message.answer("❌ Неверный формат файла. Отправьте XLSX файл.")
|
if not (message.document.file_name or "").lower().endswith(".xlsx"):
|
||||||
|
await message.answer("Отправьте XLSX файл.")
|
||||||
return
|
return
|
||||||
|
if (message.document.file_size or 0) > MAX_FILE_BYTES:
|
||||||
status_msg = await message.answer("⏳ Загружаю файл...")
|
await message.answer("Файл должен быть не больше 5 МБ.")
|
||||||
|
return
|
||||||
|
status = await message.answer("Импортирую пользователей…")
|
||||||
try:
|
try:
|
||||||
from openpyxl import load_workbook
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
# Скачиваем файл
|
|
||||||
file = await message.bot.get_file(message.document.file_id)
|
file = await message.bot.get_file(message.document.file_id)
|
||||||
file_content = await message.bot.download_file(file.file_path)
|
content = await message.bot.download_file(file.file_path)
|
||||||
|
records = await asyncio.to_thread(read_users_xlsx, content.read())
|
||||||
# Читаем Excel файл
|
added, updated, errors = await import_users(records)
|
||||||
excel_file = BytesIO(file_content.read())
|
await status.edit_text(f"Добавлено: {added}. Обновлено: {updated}. Ошибок: {errors}.")
|
||||||
wb = load_workbook(excel_file, read_only=True)
|
except ValueError as error:
|
||||||
ws = wb.active
|
await status.edit_text(str(error))
|
||||||
|
except Exception:
|
||||||
# Читаем данные
|
logger.exception("Не удалось импортировать XLSX")
|
||||||
rows = list(ws.iter_rows(values_only=True))
|
await status.edit_text("Не удалось обработать файл. Проверьте формат данных.")
|
||||||
|
finally:
|
||||||
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')
|
|
||||||
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)
|
|
||||||
|
|
||||||
await status_msg.edit_text(
|
|
||||||
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
|
|
||||||
f"⏳ Импортирую..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Импортируем пользователей
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
added_count = 0
|
|
||||||
updated_count = 0
|
|
||||||
error_count = 0
|
|
||||||
|
|
||||||
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)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
error_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем существующего пользователя
|
|
||||||
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
|
||||||
|
|
||||||
if existing_user:
|
|
||||||
# Обновляем существующего
|
|
||||||
if user_data.get('username') is not None:
|
|
||||||
existing_user.username = user_data.get('username')
|
|
||||||
if user_data.get('first_name') is not None:
|
|
||||||
existing_user.first_name = user_data.get('first_name')
|
|
||||||
if user_data.get('last_name') is not None:
|
|
||||||
existing_user.last_name = user_data.get('last_name')
|
|
||||||
if user_data.get('nickname') is not None:
|
|
||||||
existing_user.nickname = user_data.get('nickname')
|
|
||||||
if user_data.get('phone') is not None:
|
|
||||||
existing_user.phone = user_data.get('phone')
|
|
||||||
if user_data.get('club_card_number') is not None:
|
|
||||||
existing_user.club_card_number = user_data.get('club_card_number')
|
|
||||||
if user_data.get('is_registered') is not None:
|
|
||||||
existing_user.is_registered = user_data.get('is_registered', False)
|
|
||||||
if user_data.get('verification_code') is not None:
|
|
||||||
existing_user.verification_code = user_data.get('verification_code')
|
|
||||||
# is_admin не обновляем из соображений безопасности
|
|
||||||
|
|
||||||
updated_count += 1
|
|
||||||
else:
|
|
||||||
# Создаем нового
|
|
||||||
new_user = User(
|
|
||||||
telegram_id=telegram_id,
|
|
||||||
username=user_data.get('username'),
|
|
||||||
first_name=user_data.get('first_name'),
|
|
||||||
last_name=user_data.get('last_name'),
|
|
||||||
nickname=user_data.get('nickname'),
|
|
||||||
phone=user_data.get('phone'),
|
|
||||||
club_card_number=user_data.get('club_card_number'),
|
|
||||||
is_registered=user_data.get('is_registered', False),
|
|
||||||
is_admin=False, # Не импортируем админов из соображений безопасности
|
|
||||||
verification_code=user_data.get('verification_code')
|
|
||||||
)
|
|
||||||
session.add(new_user)
|
|
||||||
added_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка импорта пользователя {user_data.get('telegram_id')}: {e}")
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
# Сохраняем изменения
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Итоговый отчет
|
|
||||||
await status_msg.edit_text(
|
|
||||||
f"✅ <b>Импорт завершен!</b>\n\n"
|
|
||||||
f"📊 <b>Статистика:</b>\n"
|
|
||||||
f"➕ Добавлено: {added_count}\n"
|
|
||||||
f"🔄 Обновлено: {updated_count}\n"
|
|
||||||
f"❌ Ошибок: {error_count}\n"
|
|
||||||
f"📝 Всего обработано: {added_count + updated_count}",
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка импорта пользователей: {e}")
|
|
||||||
await status_msg.edit_text(f"❌ Ошибка импорта: {str(e)}\n\nПроверьте формат файла.")
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -5176,7 +4836,7 @@ async def admin_broadcast_channels_menu(callback: CallbackQuery, state: FSMConte
|
|||||||
|
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="✨ Добавить канал/группу", callback_data="admin_broadcast_add_channel")],
|
[InlineKeyboardButton(text="✨ Добавить канал/группу", callback_data="admin_broadcast_add_channel")],
|
||||||
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_list_channels")],
|
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_channels")],
|
||||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_broadcast")]
|
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_broadcast")]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5278,7 +4938,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
|||||||
f"• Бот не добавлен в канал/группу\n"
|
f"• Бот не добавлен в канал/группу\n"
|
||||||
f"• Неверный ID\n"
|
f"• Неверный ID\n"
|
||||||
f"• Бот не имеет прав администратора\n\n"
|
f"• Бот не имеет прав администратора\n\n"
|
||||||
f"Детали: {str(e)}",
|
f"Детали: {public_error(e)}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -5551,7 +5211,7 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.user_management_search)
|
await state.set_state(AdminStates.user_management_search)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(AdminStates.user_management_search)
|
@admin_router.message(AdminStates.user_management_search, F.text)
|
||||||
async def admin_users_search_process(message: Message, state: FSMContext):
|
async def admin_users_search_process(message: Message, state: FSMContext):
|
||||||
"""Обработка поискового запроса"""
|
"""Обработка поискового запроса"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -5932,7 +5592,7 @@ async def add_admin_start(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.admin_add_search)
|
await state.set_state(AdminStates.admin_add_search)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.admin_add_search))
|
@admin_router.message(StateFilter(AdminStates.admin_add_search), F.text)
|
||||||
async def search_user_for_admin(message: Message, state: FSMContext):
|
async def search_user_for_admin(message: Message, state: FSMContext):
|
||||||
"""Поиск пользователя для назначения админом"""
|
"""Поиск пользователя для назначения админом"""
|
||||||
if not is_super_admin(message.from_user.id):
|
if not is_super_admin(message.from_user.id):
|
||||||
@@ -6085,4 +5745,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
# Экспорт роутера
|
# Экспорт роутера
|
||||||
__all__ = ['admin_router']
|
__all__ = ['admin_router']
|
||||||
|
|||||||
100
src/handlers/cashier_handlers.py
Normal file
100
src/handlers/cashier_handlers.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from src.core.access import staff_only
|
||||||
|
from src.core.config import ADMIN_IDS, CASHIER_IDS
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User
|
||||||
|
from src.core.services import LotteryService
|
||||||
|
from src.handlers.account_services import AccountParticipationService
|
||||||
|
from src.middlewares.access import AccessMiddleware
|
||||||
|
from src.utils.account_utils import parse_accounts_from_message
|
||||||
|
|
||||||
|
cashier_router = Router(name="cashier")
|
||||||
|
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
||||||
|
cashier_router.callback_query.middleware(AccessMiddleware(allow_cashier=True))
|
||||||
|
|
||||||
|
|
||||||
|
class CashierStates(StatesGroup):
|
||||||
|
accounts = State()
|
||||||
|
|
||||||
|
|
||||||
|
@cashier_router.message(Command("cashier"))
|
||||||
|
@staff_only
|
||||||
|
async def cashier_menu(message: Message, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
lotteries = await LotteryService.get_active_lotteries(session, limit=30)
|
||||||
|
buttons = [[InlineKeyboardButton(text=lottery.title[:60], callback_data=f"cash_add:{lottery.id}")]
|
||||||
|
for lottery in lotteries]
|
||||||
|
await message.answer(
|
||||||
|
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
|
||||||
|
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
|
||||||
|
"/remove_account СЧЕТ — деактивировать счет\n"
|
||||||
|
"/verify_winner КОД ID_РОЗЫГРЫША — подтвердить выдачу приза\n"
|
||||||
|
"/winner_status ID_РОЗЫГРЫША — проверить выдачу\n"
|
||||||
|
"/cancel — отменить ввод",
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cashier_router.callback_query(F.data == "cashier_panel")
|
||||||
|
async def cashier_menu_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await callback.answer()
|
||||||
|
# Do not use the bot-authored message as the identity for authorization.
|
||||||
|
await cashier_menu.__wrapped__(callback.message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@cashier_router.callback_query(F.data.startswith("cash_add:"))
|
||||||
|
async def choose_draw(callback: CallbackQuery, state: FSMContext):
|
||||||
|
lottery_id = int(callback.data.split(":")[1])
|
||||||
|
await state.clear()
|
||||||
|
await state.update_data(lottery_id=lottery_id)
|
||||||
|
await state.set_state(CashierStates.accounts)
|
||||||
|
await callback.answer()
|
||||||
|
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
|
||||||
|
|
||||||
|
|
||||||
|
@cashier_router.message(CashierStates.accounts, F.text)
|
||||||
|
async def add_accounts(message: Message, state: FSMContext):
|
||||||
|
accounts = parse_accounts_from_message(message.text)
|
||||||
|
if not accounts or len(accounts) > 1000:
|
||||||
|
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
|
||||||
|
return
|
||||||
|
data = await state.get_data()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await AccountParticipationService.add_accounts_bulk(session, data["lottery_id"], accounts)
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
|
||||||
|
+ "\n".join(result["errors"][:10]))
|
||||||
|
|
||||||
|
|
||||||
|
@cashier_router.message(Command("add_cashier", "remove_cashier", "cashiers"))
|
||||||
|
async def manage_cashiers(message: Message):
|
||||||
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
|
await message.answer("Назначать кассиров может только главный администратор.")
|
||||||
|
return
|
||||||
|
parts = message.text.split()
|
||||||
|
command = parts[0].split("@")[0].lower()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
if command == "/cashiers":
|
||||||
|
ids = set((await session.scalars(select(User.telegram_id).where(User.is_cashier.is_(True)))).all())
|
||||||
|
ids.update(CASHIER_IDS)
|
||||||
|
await message.answer("Кассиры:\n" + ("\n".join(map(str, sorted(ids))) or "Нет назначенных кассиров"))
|
||||||
|
return
|
||||||
|
if len(parts) != 2 or not parts[1].isascii() or not parts[1].isdigit():
|
||||||
|
await message.answer(f"Формат: {command} TELEGRAM_ID")
|
||||||
|
return
|
||||||
|
telegram_id = int(parts[1])
|
||||||
|
if telegram_id in CASHIER_IDS and command == "/remove_cashier":
|
||||||
|
await message.answer("Этот кассир задан в CASHIER_IDS; удалите ID из настройки окружения.")
|
||||||
|
return
|
||||||
|
changed = await session.execute(update(User).where(User.telegram_id == telegram_id)
|
||||||
|
.values(is_cashier=command == "/add_cashier"))
|
||||||
|
await session.commit()
|
||||||
|
await message.answer("✅ Права обновлены" if changed.rowcount else "Пользователь должен сначала выполнить /start.")
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
"""Обработчики пользовательских сообщений в чате"""
|
"""Обработчики пользовательских сообщений в чате"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
|
from src.core.access import is_admin
|
||||||
|
from src.utils.delivery import background_delivery
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
@@ -10,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Optional, Set, Any
|
from typing import List, Dict, Optional, Set, Any
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from src.utils.telegram_messages import copy_preserving_entities
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from src.core.chat_services import (
|
from src.core.chat_services import (
|
||||||
@@ -29,9 +33,6 @@ class ChatStates(StatesGroup):
|
|||||||
in_chat = State() # Пользователь находится в режиме чата
|
in_chat = State() # Пользователь находится в режиме чата
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка является ли пользователь админом"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
def _contains_account_numbers(text: str) -> bool:
|
def _contains_account_numbers(text: str) -> bool:
|
||||||
@@ -98,10 +99,10 @@ async def exit_chat_command(message: Message, state: FSMContext):
|
|||||||
async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
|
async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Выйти из режима чата через кнопку"""
|
"""Выйти из режима чата через кнопку"""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
await exit_chat(callback.message, state)
|
await exit_chat(callback.message, state, actor=callback.from_user)
|
||||||
|
|
||||||
|
|
||||||
async def exit_chat(message: Message, state: FSMContext):
|
async def exit_chat(message: Message, state: FSMContext, actor=None):
|
||||||
"""Общая функция выхода из чата"""
|
"""Общая функция выхода из чата"""
|
||||||
from src.utils.keyboards import get_main_reply_keyboard
|
from src.utils.keyboards import get_main_reply_keyboard
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
@@ -112,9 +113,9 @@ async def exit_chat(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
# Получаем информацию о пользователе
|
# Получаем информацию о пользователе
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_user_by_telegram_id(session, (actor or message.from_user).id)
|
||||||
is_registered = user.is_registered if user else False
|
is_registered = user.is_registered if user else False
|
||||||
is_admin_user = message.from_user.id in ADMIN_IDS
|
is_admin_user = await is_admin((actor or message.from_user).id)
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||||
@@ -171,7 +172,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
||||||
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
||||||
# Пропускаем для account_router (который идет после chat_router)
|
# Пропускаем для account_router (который идет после chat_router)
|
||||||
if is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
if await is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
||||||
if _contains_account_numbers(message.text):
|
if _contains_account_numbers(message.text):
|
||||||
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает")
|
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает")
|
||||||
# Не делаем return, выбрасываем исключение для пропуска в следующий обработчик
|
# Не делаем return, выбрасываем исключение для пропуска в следующий обработчик
|
||||||
@@ -187,13 +188,14 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
raise SkipHandler()
|
raise SkipHandler()
|
||||||
|
|
||||||
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
||||||
if message.reply_to_message and is_admin(message.from_user.id):
|
if message.reply_to_message and await is_admin(message.from_user.id):
|
||||||
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
|
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Ищем сообщение в БД по telegram_message_id
|
# Ищем сообщение в БД по telegram_message_id
|
||||||
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
|
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
|
||||||
session,
|
session,
|
||||||
telegram_message_id=message.reply_to_message.message_id
|
telegram_message_id=message.reply_to_message.message_id,
|
||||||
|
chat_id=message.chat.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if msg_to_delete:
|
if msg_to_delete:
|
||||||
@@ -271,7 +273,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
command = message.text.split()[0] if message.text else ''
|
command = message.text.split()[0] if message.text else ''
|
||||||
|
|
||||||
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
|
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
|
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
|
||||||
if command in admin_commands:
|
if command in admin_commands:
|
||||||
return
|
return
|
||||||
@@ -290,7 +292,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -314,6 +316,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
# Режим рассылки с планировщиком
|
# Режим рассылки с планировщиком
|
||||||
# Передаем объект user для динамического формирования подписей
|
# Передаем объект user для динамического формирования подписей
|
||||||
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
|
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -331,7 +334,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику доставки только админам
|
# Показываем статистику доставки только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ Сообщение разослано!\n"
|
f"✅ Сообщение разослано!\n"
|
||||||
f"📤 Доставлено: {success}\n"
|
f"📤 Доставлено: {success}\n"
|
||||||
@@ -370,11 +373,12 @@ BATCH_DELAY = 1.0 # Задержка между пакетами в секун
|
|||||||
_processed_messages: deque = deque(maxlen=100)
|
_processed_messages: deque = deque(maxlen=100)
|
||||||
|
|
||||||
|
|
||||||
def _is_message_processed(message_id: int) -> bool:
|
def _is_message_processed(chat_id: int, message_id: int) -> bool:
|
||||||
"""Проверка, было ли сообщение уже обработано"""
|
"""Проверка, было ли сообщение уже обработано"""
|
||||||
if message_id in _processed_messages:
|
key = (chat_id, message_id)
|
||||||
|
if key in _processed_messages:
|
||||||
return True
|
return True
|
||||||
_processed_messages.append(message_id)
|
_processed_messages.append(key)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -420,7 +424,7 @@ async def broadcast_message_with_scheduler(
|
|||||||
|
|
||||||
# Если только для админов - фильтруем
|
# Если только для админов - фильтруем
|
||||||
if admin_only:
|
if admin_only:
|
||||||
users = [u for u in users if u.telegram_id in ADMIN_IDS]
|
users = [u for u in users if (u.telegram_id in ADMIN_IDS or u.is_admin)]
|
||||||
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
|
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
|
||||||
|
|
||||||
forwarded_ids = {}
|
forwarded_ids = {}
|
||||||
@@ -435,7 +439,7 @@ async def broadcast_message_with_scheduler(
|
|||||||
tasks = []
|
tasks = []
|
||||||
for recipient_user in batch:
|
for recipient_user in batch:
|
||||||
# Формируем подпись в зависимости от получателя
|
# Формируем подпись в зависимости от получателя
|
||||||
if recipient_user.telegram_id in ADMIN_IDS:
|
if (recipient_user.telegram_id in ADMIN_IDS or recipient_user.is_admin):
|
||||||
# Админы видят полную информацию: nickname (карта: XXXX)
|
# Админы видят полную информацию: nickname (карта: XXXX)
|
||||||
sender_name = sender_user.nickname if sender_user.nickname else (
|
sender_name = sender_user.nickname if sender_user.nickname else (
|
||||||
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
|
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
|
||||||
@@ -448,7 +452,7 @@ async def broadcast_message_with_scheduler(
|
|||||||
# Обычные пользователи видят:
|
# Обычные пользователи видят:
|
||||||
# - "Админ" если отправитель - админ
|
# - "Админ" если отправитель - админ
|
||||||
# - nickname если отправитель - обычный пользователь
|
# - nickname если отправитель - обычный пользователь
|
||||||
if sender_user.telegram_id in ADMIN_IDS:
|
if (sender_user.telegram_id in ADMIN_IDS or sender_user.is_admin):
|
||||||
sender_info = "Админ"
|
sender_info = "Админ"
|
||||||
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
|
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
|
||||||
else:
|
else:
|
||||||
@@ -478,175 +482,38 @@ async def broadcast_message_with_scheduler(
|
|||||||
return forwarded_ids, success_count, fail_count
|
return forwarded_ids, success_count, fail_count
|
||||||
|
|
||||||
|
|
||||||
|
@background_delivery
|
||||||
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) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Отправить сообщение конкретному пользователю.
|
Отправить сообщение конкретному пользователю.
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
sent_msg = await message.copy_to(user_telegram_id)
|
sent_msg = await copy_preserving_entities(message, user_telegram_id)
|
||||||
return sent_msg.message_id
|
return sent_msg.message_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send message to {user_telegram_id}: {e}")
|
print(f"Failed to send message to {user_telegram_id}: {public_error(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@background_delivery
|
||||||
|
async def _copy_with_sender(message: Message, recipient_id: int, sender_info: str):
|
||||||
|
"""Preserve Telegram entities and keep headers within message/caption limits."""
|
||||||
|
try:
|
||||||
|
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
|
||||||
|
return sent.message_id
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("Chat delivery failed")
|
||||||
return None
|
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) -> Optional[int]:
|
||||||
"""
|
return await _copy_with_sender(message, user_telegram_id, sender_info)
|
||||||
Отправить сообщение обычному пользователю с информацией об отправителе.
|
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Формируем текст с информацией об отправителе
|
|
||||||
header = f"📨 <b>{sender_info}:</b>\n\n"
|
|
||||||
|
|
||||||
if message.text:
|
|
||||||
# Текстовое сообщение
|
|
||||||
sent_msg = await message.bot.send_message(
|
|
||||||
user_telegram_id,
|
|
||||||
header + message.text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.photo:
|
|
||||||
# Фото
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_photo(
|
|
||||||
user_telegram_id,
|
|
||||||
photo=message.photo[-1].file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video:
|
|
||||||
# Видео
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_video(
|
|
||||||
user_telegram_id,
|
|
||||||
video=message.video.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.document:
|
|
||||||
# Документ
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_document(
|
|
||||||
user_telegram_id,
|
|
||||||
document=message.document.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.animation:
|
|
||||||
# GIF
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_animation(
|
|
||||||
user_telegram_id,
|
|
||||||
animation=message.animation.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.sticker:
|
|
||||||
# Стикер - сначала отправляем заголовок, потом стикер
|
|
||||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
|
|
||||||
elif message.voice:
|
|
||||||
# Голосовое сообщение
|
|
||||||
sent_msg = await message.bot.send_voice(
|
|
||||||
user_telegram_id,
|
|
||||||
voice=message.voice.file_id,
|
|
||||||
caption=header,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video_note:
|
|
||||||
# Видео-кружок
|
|
||||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
|
|
||||||
else:
|
|
||||||
# Неизвестный тип - просто копируем
|
|
||||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.copy_to(user_telegram_id)
|
|
||||||
|
|
||||||
return sent_msg.message_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send message to {user_telegram_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||||
"""
|
return await _copy_with_sender(message, admin_telegram_id, sender_info)
|
||||||
Отправить сообщение админу с информацией об отправителе.
|
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Формируем текст с информацией об отправителе
|
|
||||||
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
|
||||||
|
|
||||||
if message.text:
|
|
||||||
# Текстовое сообщение
|
|
||||||
sent_msg = await message.bot.send_message(
|
|
||||||
admin_telegram_id,
|
|
||||||
header + message.text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.photo:
|
|
||||||
# Фото
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_photo(
|
|
||||||
admin_telegram_id,
|
|
||||||
photo=message.photo[-1].file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video:
|
|
||||||
# Видео
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_video(
|
|
||||||
admin_telegram_id,
|
|
||||||
video=message.video.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.document:
|
|
||||||
# Документ
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_document(
|
|
||||||
admin_telegram_id,
|
|
||||||
document=message.document.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.animation:
|
|
||||||
# GIF
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_animation(
|
|
||||||
admin_telegram_id,
|
|
||||||
animation=message.animation.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.sticker:
|
|
||||||
# Стикер - сначала отправляем заголовок, потом стикер
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
|
||||||
elif message.voice:
|
|
||||||
# Голосовое сообщение
|
|
||||||
sent_msg = await message.bot.send_voice(
|
|
||||||
admin_telegram_id,
|
|
||||||
voice=message.voice.file_id,
|
|
||||||
caption=header,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video_note:
|
|
||||||
# Видео-кружок
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
|
||||||
else:
|
|
||||||
# Неизвестный тип - просто копируем
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.copy_to(admin_telegram_id)
|
|
||||||
|
|
||||||
return sent_msg.message_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
||||||
@@ -656,7 +523,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
|||||||
sent_msg = await message.forward(channel_id)
|
sent_msg = await message.forward(channel_id)
|
||||||
return True, sent_msg.message_id
|
return True, sent_msg.message_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to forward message to channel {channel_id}: {e}")
|
print(f"Failed to forward message to channel {channel_id}: {public_error(e)}")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
@@ -665,14 +532,14 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
|||||||
async def handle_photo_message(message: Message, state: FSMContext):
|
async def handle_photo_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик фото"""
|
"""Обработчик фото"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -693,6 +560,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Рассылаем фото - ВСЕГДА исключаем отправителя
|
# Рассылаем фото - ВСЕГДА исключаем отправителя
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -710,7 +578,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -734,14 +602,14 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
async def handle_video_message(message: Message, state: FSMContext):
|
async def handle_video_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик видео"""
|
"""Обработчик видео"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -759,6 +627,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Рассылаем видео
|
# Рассылаем видео
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -776,7 +645,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -800,14 +669,14 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
async def handle_document_message(message: Message, state: FSMContext):
|
async def handle_document_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик документов"""
|
"""Обработчик документов"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -825,6 +694,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Рассылаем документ
|
# Рассылаем документ
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -842,7 +712,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -866,14 +736,14 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
async def handle_animation_message(message: Message, state: FSMContext):
|
async def handle_animation_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик GIF анимаций"""
|
"""Обработчик GIF анимаций"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -891,6 +761,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Рассылаем анимацию
|
# Рассылаем анимацию
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -908,7 +779,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -932,14 +803,14 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
async def handle_sticker_message(message: Message, state: FSMContext):
|
async def handle_sticker_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик стикеров"""
|
"""Обработчик стикеров"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.chat.id, message.message_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
can_send, reason = await ChatPermissionService.can_send_message(
|
can_send, reason = await ChatPermissionService.can_send_message(
|
||||||
session,
|
session,
|
||||||
message.from_user.id,
|
message.from_user.id,
|
||||||
is_admin=is_admin(message.from_user.id)
|
is_admin=await is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -957,6 +828,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Рассылаем стикер
|
# Рассылаем стикер
|
||||||
|
await session.commit()
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
sender_user=user,
|
sender_user=user,
|
||||||
@@ -973,7 +845,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Обработчики справки и помощи пользователям"""
|
"""Обработчики справки и помощи пользователям"""
|
||||||
|
from src.core.access import is_admin
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -7,9 +8,6 @@ from src.core.config import ADMIN_IDS
|
|||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка является ли пользователь админом"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
router = Router(name='help_router')
|
router = Router(name='help_router')
|
||||||
@@ -241,7 +239,7 @@ async def help_commands(callback: CallbackQuery):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
is_user_admin = is_admin(user_id)
|
is_user_admin = await is_admin(user_id)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"⚙️ <b>Список команд бота</b>\n\n"
|
"⚙️ <b>Список команд бота</b>\n\n"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Хэндлеры для управления сообщениями администратором
|
Хэндлеры для управления сообщениями администратором
|
||||||
"""
|
"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
|
from src.core.access import is_admin
|
||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
@@ -17,9 +19,6 @@ logger = logging.getLogger(__name__)
|
|||||||
message_admin_router = Router(name="message_admin")
|
message_admin_router = Router(name="message_admin")
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка, является ли пользователь администратором"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.message(CaseInsensitiveCommand("delete"))
|
@message_admin_router.message(CaseInsensitiveCommand("delete"))
|
||||||
@@ -28,7 +27,7 @@ async def delete_replied_message(message: Message):
|
|||||||
Удаление сообщения по команде /delete (регистронезависимо)
|
Удаление сообщения по команде /delete (регистронезависимо)
|
||||||
Работает только если команда является ответом на сообщение бота
|
Работает только если команда является ответом на сообщение бота
|
||||||
"""
|
"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ async def delete_replied_message(message: Message):
|
|||||||
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
|
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||||
await message.answer(f"❌ Не удалось удалить сообщение: {str(e)}")
|
await message.answer(f"❌ Не удалось удалить сообщение: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.callback_query(F.data == "delete_message")
|
@message_admin_router.callback_query(F.data == "delete_message")
|
||||||
@@ -56,7 +55,7 @@ async def delete_message_callback(callback: CallbackQuery):
|
|||||||
"""
|
"""
|
||||||
Удаление сообщения по нажатию кнопки
|
Удаление сообщения по нажатию кнопки
|
||||||
"""
|
"""
|
||||||
if not is_admin(callback.from_user.id):
|
if not await is_admin(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -66,7 +65,7 @@ async def delete_message_callback(callback: CallbackQuery):
|
|||||||
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
|
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
|
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
|
||||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# Функция-фильтр для проверки триггерных слов
|
# Функция-фильтр для проверки триггерных слов
|
||||||
@@ -93,7 +92,7 @@ async def quick_delete_replied_message(message: Message):
|
|||||||
|
|
||||||
Удаляет сообщение у всех получателей broadcast рассылки
|
Удаляет сообщение у всех получателей broadcast рассылки
|
||||||
"""
|
"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
return # Не админ - пропускаем
|
return # Не админ - пропускаем
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -115,7 +114,8 @@ async def quick_delete_replied_message(message: Message):
|
|||||||
|
|
||||||
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||||
session,
|
session,
|
||||||
telegram_message_id=replied_msg.message_id
|
telegram_message_id=replied_msg.message_id,
|
||||||
|
chat_id=message.chat.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если нашли broadcast сообщение - удаляем у всех получателей
|
# Если нашли broadcast сообщение - удаляем у всех получателей
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Обработчики P2P чата между пользователями"""
|
"""Обработчики P2P чата между пользователями"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
|
from src.utils.telegram_messages import copy_preserving_entities
|
||||||
|
from html import escape
|
||||||
|
from src.core.access import is_admin
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
@@ -25,9 +29,6 @@ class P2PChatStates(StatesGroup):
|
|||||||
chatting = State() # В процессе переписки с пользователем
|
chatting = State() # В процессе переписки с пользователем
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
|
||||||
"""Проверка прав администратора"""
|
|
||||||
return user_id in ADMIN_IDS
|
|
||||||
|
|
||||||
|
|
||||||
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
|
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
|
||||||
@@ -105,7 +106,7 @@ async def show_chat_menu(message: Message, state: FSMContext):
|
|||||||
)]
|
)]
|
||||||
]
|
]
|
||||||
|
|
||||||
if is_admin(message.from_user.id):
|
if await is_admin(message.from_user.id):
|
||||||
buttons.append([InlineKeyboardButton(
|
buttons.append([InlineKeyboardButton(
|
||||||
text="📢 Написать всем (broadcast)",
|
text="📢 Написать всем (broadcast)",
|
||||||
callback_data="p2p:broadcast"
|
callback_data="p2p:broadcast"
|
||||||
@@ -126,7 +127,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
|||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Получаем всех зарегистрированных пользователей кроме себя
|
# Получаем всех зарегистрированных пользователей кроме себя
|
||||||
users = await UserService.get_all_users(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]
|
users = [u for u in users if u.telegram_id != callback.from_user.id and (u.is_registered or u.is_admin or u.telegram_id in ADMIN_IDS)]
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||||
@@ -136,7 +137,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
|||||||
buttons = []
|
buttons = []
|
||||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||||
if user.club_card_number:
|
if user.club_card_number and await is_admin(callback.from_user.id):
|
||||||
display_name += f" (карта: {user.club_card_number})"
|
display_name += f" (карта: {user.club_card_number})"
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(
|
buttons.append([InlineKeyboardButton(
|
||||||
@@ -201,10 +202,10 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
|||||||
# Определяем имя отправителя
|
# Определяем имя отправителя
|
||||||
is_current = msg.sender_id == sender.id
|
is_current = msg.sender_id == sender.id
|
||||||
user_for_display = sender if is_current else recipient
|
user_for_display = sender if is_current else recipient
|
||||||
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
sender_name = format_sender_name(user_for_display, is_current, await is_admin(sender.telegram_id))
|
||||||
|
|
||||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||||
text += f"• {sender_name}: {msg_text}\n"
|
text += f"• {escape(sender_name)}: {escape(msg_text)}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
|
|
||||||
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||||||
@@ -267,12 +268,12 @@ async def show_conversations(callback: CallbackQuery):
|
|||||||
callback_data=f"p2p:user:{peer.id}"
|
callback_data=f"p2p:user:{peer.id}"
|
||||||
)])
|
)])
|
||||||
|
|
||||||
text += f"{icon} <b>{peer_name}</b>"
|
text += f"{icon} <b>{escape(peer_name)}</b>"
|
||||||
# Показываем номер карты если есть
|
# Показываем номер карты если есть
|
||||||
if peer.club_card_number:
|
if peer.club_card_number and await is_admin(callback.from_user.id):
|
||||||
text += f" (карта: {peer.club_card_number})"
|
text += f" (карта: {escape(peer.club_card_number)})"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
text += f" {preview}\n"
|
text += f" {escape(preview)}\n"
|
||||||
if unread > 0:
|
if unread > 0:
|
||||||
text += f" 📨 Непрочитанных: {unread}\n"
|
text += f" 📨 Непрочитанных: {unread}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
@@ -384,13 +385,13 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
|
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
|
||||||
|
|
||||||
# Формируем подпись сообщения для получателя
|
# Формируем подпись сообщения для получателя
|
||||||
if sender.is_admin:
|
if await is_admin(sender.telegram_id):
|
||||||
sender_name = "АДМИН"
|
sender_name = "АДМИН"
|
||||||
else:
|
else:
|
||||||
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
sender_name = sender.nickname or (f"@{sender.username}" if sender.username else sender.first_name) or "Участник"
|
||||||
|
|
||||||
# Добавляем карту если получатель админ
|
# Добавляем карту если получатель админ
|
||||||
if recipient and recipient.is_admin and sender.club_card_number:
|
if recipient and await is_admin(recipient.telegram_id) and sender.club_card_number:
|
||||||
sender_name += f" (карта: {sender.club_card_number})"
|
sender_name += f" (карта: {sender.club_card_number})"
|
||||||
|
|
||||||
# Определяем тип сообщения
|
# Определяем тип сообщения
|
||||||
@@ -411,35 +412,11 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
file_id = message.document.file_id
|
file_id = message.document.file_id
|
||||||
text = message.caption
|
text = message.caption
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
# Отправляем сообщение получателю
|
# Отправляем сообщение получателю
|
||||||
try:
|
try:
|
||||||
if message_type == "text":
|
sent = await copy_preserving_entities(message, recipient_telegram_id, sender_name)
|
||||||
sent = 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(
|
|
||||||
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(
|
|
||||||
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(
|
|
||||||
recipient_telegram_id,
|
|
||||||
document=file_id,
|
|
||||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сохраняем в БД
|
# Сохраняем в БД
|
||||||
await P2PMessageService.send_message(
|
await P2PMessageService.send_message(
|
||||||
@@ -456,4 +433,14 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Сообщение доставлено")
|
await message.answer("✅ Сообщение доставлено")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|
await message.answer(f"❌ Не удалось доставить сообщение: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "p2p:broadcast")
|
||||||
|
async def open_broadcast(callback: CallbackQuery, state: FSMContext):
|
||||||
|
if not await is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("Недостаточно прав", show_alert=True)
|
||||||
|
return
|
||||||
|
from src.handlers.admin_panel import admin_broadcast_menu
|
||||||
|
await admin_broadcast_menu(callback, state)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -70,7 +71,10 @@ async def check_unclaimed_winners(message: Message):
|
|||||||
for winner in winners:
|
for winner in winners:
|
||||||
if not winner.is_claimed and winner.is_notified:
|
if not winner.is_claimed and winner.is_notified:
|
||||||
# Проверяем, прошло ли 24 часа
|
# Проверяем, прошло ли 24 часа
|
||||||
time_passed = now - winner.created_at
|
created = winner.created_at
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
time_passed = now - created
|
||||||
if time_passed.total_seconds() > 24 * 3600: # 24 часа
|
if time_passed.total_seconds() > 24 * 3600: # 24 часа
|
||||||
unclaimed.append({
|
unclaimed.append({
|
||||||
'winner': winner,
|
'winner': winner,
|
||||||
@@ -116,195 +120,25 @@ async def check_unclaimed_winners(message: Message):
|
|||||||
await message.answer(text, parse_mode="Markdown")
|
await message.answer(text, parse_mode="Markdown")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("redraw"))
|
@router.message(CaseInsensitiveCommand("redraw"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def redraw_lottery(message: Message):
|
async def redraw_lottery(message: Message):
|
||||||
"""
|
from src.core.redraw_services import redraw_unclaimed
|
||||||
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
|
from src.utils.notifications import notify_winners_async
|
||||||
Формат: /redraw <lottery_id>
|
|
||||||
"""
|
|
||||||
|
|
||||||
parts = message.text.split()
|
parts = message.text.split()
|
||||||
if len(parts) != 2:
|
if len(parts) != 2 or not parts[1].isdigit():
|
||||||
await message.answer(
|
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
|
||||||
"❌ Неверный формат команды\n\n"
|
|
||||||
"Используйте: /redraw <lottery_id>"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
lottery_id = int(parts[1])
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
winners = await redraw_unclaimed(session, lottery_id)
|
||||||
|
if winners:
|
||||||
|
await notify_winners_async(message.bot, session, lottery_id)
|
||||||
|
await message.answer(f"Повторно разыграно призов: {len(winners)}. Учитываются уведомлённые победители, не подтвердившие выигрыш за 24 часа.")
|
||||||
|
|
||||||
try:
|
|
||||||
lottery_id = int(parts[1])
|
|
||||||
except ValueError:
|
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from src.core.models import Lottery
|
|
||||||
|
|
||||||
# Загружаем розыгрыш с участниками
|
|
||||||
lottery_result = await session.execute(
|
|
||||||
select(Lottery)
|
|
||||||
.options(selectinload(Lottery.participations))
|
|
||||||
.where(Lottery.id == lottery_id)
|
|
||||||
)
|
|
||||||
lottery = lottery_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not lottery:
|
|
||||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
|
||||||
return
|
|
||||||
|
|
||||||
winners = await LotteryService.get_winners(session, lottery_id)
|
|
||||||
|
|
||||||
# Находим неподтвержденные выигрыши старше 24 часов
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
unclaimed_winners = []
|
|
||||||
|
|
||||||
for winner in winners:
|
|
||||||
if not winner.is_claimed and winner.is_notified:
|
|
||||||
time_passed = now - winner.created_at
|
|
||||||
if time_passed.total_seconds() > 24 * 3600:
|
|
||||||
unclaimed_winners.append(winner)
|
|
||||||
|
|
||||||
if not unclaimed_winners:
|
|
||||||
await message.answer(
|
|
||||||
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
|
|
||||||
"Повторный розыгрыш не требуется."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Получаем всех участников, исключая текущих победителей
|
|
||||||
all_participants = []
|
|
||||||
current_winner_accounts = set()
|
|
||||||
|
|
||||||
for winner in winners:
|
|
||||||
if winner.account_number:
|
|
||||||
current_winner_accounts.add(winner.account_number)
|
|
||||||
|
|
||||||
for p in lottery.participations:
|
|
||||||
if p.account_number and p.account_number not in current_winner_accounts:
|
|
||||||
all_participants.append(p)
|
|
||||||
|
|
||||||
if not all_participants:
|
|
||||||
await message.answer(
|
|
||||||
"❌ Нет доступных участников для повторного розыгрыша.\n"
|
|
||||||
"Все участники уже являются победителями."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Переигрываем каждое неподтвержденное место
|
|
||||||
redraw_results = []
|
|
||||||
|
|
||||||
for old_winner in unclaimed_winners:
|
|
||||||
if not all_participants:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Выбираем нового победителя
|
|
||||||
new_participant = random.choice(all_participants)
|
|
||||||
all_participants.remove(new_participant)
|
|
||||||
|
|
||||||
# Удаляем старого победителя
|
|
||||||
await session.delete(old_winner)
|
|
||||||
|
|
||||||
# Создаем нового победителя
|
|
||||||
new_winner = Winner(
|
|
||||||
lottery_id=lottery_id,
|
|
||||||
user_id=None,
|
|
||||||
account_number=new_participant.account_number,
|
|
||||||
account_id=new_participant.account_id,
|
|
||||||
place=old_winner.place,
|
|
||||||
prize=old_winner.prize,
|
|
||||||
is_manual=False,
|
|
||||||
is_notified=False,
|
|
||||||
is_claimed=False
|
|
||||||
)
|
|
||||||
session.add(new_winner)
|
|
||||||
|
|
||||||
redraw_results.append({
|
|
||||||
'place': old_winner.place,
|
|
||||||
'prize': old_winner.prize,
|
|
||||||
'old_account': old_winner.account_number,
|
|
||||||
'new_account': new_participant.account_number
|
|
||||||
})
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Отправляем уведомления новым победителям
|
|
||||||
for result in redraw_results:
|
|
||||||
# Находим нового победителя
|
|
||||||
new_winner_result = await session.execute(
|
|
||||||
select(Winner).where(
|
|
||||||
and_(
|
|
||||||
Winner.lottery_id == lottery_id,
|
|
||||||
Winner.place == result['place'],
|
|
||||||
Winner.account_number == result['new_account']
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
new_winner = new_winner_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if new_winner:
|
|
||||||
# Отправляем уведомление новому победителю
|
|
||||||
owner = await AccountService.get_account_owner(session, new_winner.account_number)
|
|
||||||
|
|
||||||
if owner and owner.telegram_id:
|
|
||||||
# Создаем токен верификации
|
|
||||||
await WinnerNotificationService.create_verification_token(
|
|
||||||
session,
|
|
||||||
new_winner.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Формируем сообщение
|
|
||||||
notification_message = (
|
|
||||||
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
|
|
||||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
|
||||||
f"🏆 Место: {new_winner.place}\n"
|
|
||||||
f"🎁 Приз: {new_winner.prize}\n"
|
|
||||||
f"💳 Счет: {new_winner.account_number}\n\n"
|
|
||||||
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
|
||||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
|
|
||||||
)
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
text="✅ Подтвердить получение приза",
|
|
||||||
callback_data=f"confirm_win_{new_winner.id}"
|
|
||||||
)]
|
|
||||||
])
|
|
||||||
|
|
||||||
try:
|
|
||||||
await message.bot.send_message(
|
|
||||||
owner.telegram_id,
|
|
||||||
notification_message,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_winner.is_notified = True
|
|
||||||
await session.commit()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Формируем отчет для админа
|
|
||||||
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
|
|
||||||
text += f"🎯 Розыгрыш: {lottery.title}\n"
|
|
||||||
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
|
|
||||||
|
|
||||||
for result in redraw_results:
|
|
||||||
text += f"🏆 {result['place']} место - {result['prize']}\n"
|
|
||||||
text += f" ❌ Было: {result['old_account']}\n"
|
|
||||||
text += f" ✅ Стало: {result['new_account']}\n\n"
|
|
||||||
|
|
||||||
text += "📨 Новым победителям отправлены уведомления"
|
|
||||||
|
|
||||||
await message.answer(text, parse_mode="Markdown")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("confirm_win_"))
|
@router.callback_query(F.data.startswith("confirm_win_"))
|
||||||
@@ -341,10 +175,16 @@ async def confirm_winner_callback(callback_query):
|
|||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
owner = await session.get(User, winner.user_id) if winner.user_id else None
|
||||||
|
if not owner or owner.telegram_id != callback_query.from_user.id:
|
||||||
|
await callback_query.answer("❌ Это приз другого пользователя", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
# Проверяем срок действия (24 часа с момента создания winner)
|
# Проверяем срок действия (24 часа с момента создания winner)
|
||||||
if winner.created_at:
|
if winner.created_at:
|
||||||
time_since_creation = datetime.now(timezone.utc) - winner.created_at
|
created_at = winner.created_at.replace(tzinfo=timezone.utc) if winner.created_at.tzinfo is None else winner.created_at
|
||||||
|
time_since_creation = datetime.now(timezone.utc) - created_at
|
||||||
if time_since_creation > timedelta(hours=24):
|
if time_since_creation > timedelta(hours=24):
|
||||||
await callback_query.answer(
|
await callback_query.answer(
|
||||||
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
|
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
|
||||||
@@ -353,8 +193,17 @@ async def confirm_winner_callback(callback_query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Подтверждаем выигрыш
|
# Подтверждаем выигрыш
|
||||||
winner.is_claimed = True
|
from src.core.models import Lottery
|
||||||
winner.claimed_at = datetime.now(timezone.utc)
|
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == winner.lottery_id)):
|
||||||
|
await callback_query.answer("Розыгрыш ещё не завершён", show_alert=True)
|
||||||
|
return
|
||||||
|
from sqlalchemy import update
|
||||||
|
changed = await session.execute(update(Winner).where(Winner.id == winner_id, Winner.is_claimed.is_(False))
|
||||||
|
.values(is_claimed=True, claimed_at=datetime.now(timezone.utc)))
|
||||||
|
if changed.rowcount != 1:
|
||||||
|
await session.rollback()
|
||||||
|
await callback_query.answer("Приз уже подтвержден", show_alert=True)
|
||||||
|
return
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Получаем данные о розыгрыше и пользователе
|
# Получаем данные о розыгрыше и пользователе
|
||||||
@@ -419,7 +268,6 @@ async def confirm_winner_callback(callback_query):
|
|||||||
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
|
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
|
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {public_error(e)}")
|
||||||
|
|
||||||
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Обработчики для регистрации пользователей"""
|
"""Обработчики для регистрации пользователей"""
|
||||||
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
@@ -19,6 +20,22 @@ logger = logging.getLogger(__name__)
|
|||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def begin_registration(message: Message, state: FSMContext, actor=None):
|
||||||
|
actor = actor or message.from_user
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, actor.id, actor.username, actor.first_name, actor.last_name)
|
||||||
|
if user.is_registered:
|
||||||
|
await message.answer("Вы уже зарегистрированы. /my_accounts — ваши счета.")
|
||||||
|
return
|
||||||
|
await state.clear()
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||||
|
await message.answer(
|
||||||
|
"📝 Регистрация\n\nШаг 1 из 3: введите никнейм для чата (от 2 до 20 символов).",
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
|
||||||
|
InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Служебные слова, которые нельзя использовать как никнейм
|
# Служебные слова, которые нельзя использовать как никнейм
|
||||||
FORBIDDEN_NICKNAMES = [
|
FORBIDDEN_NICKNAMES = [
|
||||||
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
||||||
@@ -56,6 +73,8 @@ def validate_nickname(nickname: str) -> tuple[bool, str]:
|
|||||||
if nickname.startswith('/'):
|
if nickname.startswith('/'):
|
||||||
return False, "❌ Никнейм не может начинаться с '/'"
|
return False, "❌ Никнейм не может начинаться с '/'"
|
||||||
|
|
||||||
|
if not all(char.isalnum() or char in " _-" for char in nickname):
|
||||||
|
return False, "❌ Используйте буквы, цифры, пробел, дефис или подчёркивание"
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
@@ -68,29 +87,12 @@ class RegistrationStates(StatesGroup):
|
|||||||
|
|
||||||
@router.callback_query(F.data == "start_registration")
|
@router.callback_query(F.data == "start_registration")
|
||||||
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Начать процесс регистрации"""
|
await callback.answer()
|
||||||
logger.info(f"Получен запрос на регистрацию от пользователя {callback.from_user.id}")
|
await begin_registration(callback.message, state, actor=callback.from_user)
|
||||||
|
|
||||||
text = (
|
|
||||||
"📝 Регистрация в системе\n\n"
|
|
||||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
|
||||||
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
|
||||||
"🎭 Введите ваш никнейм для чата:\n"
|
|
||||||
"• От 2 до 20 символов\n"
|
|
||||||
"• Может содержать буквы, цифры, пробелы\n"
|
|
||||||
"• Это имя будут видеть другие участники"
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text,
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_nickname))
|
|
||||||
|
@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text)
|
||||||
async def process_nickname(message: Message, state: FSMContext):
|
async def process_nickname(message: Message, state: FSMContext):
|
||||||
"""Обработка никнейма"""
|
"""Обработка никнейма"""
|
||||||
nickname = message.text.strip()
|
nickname = message.text.strip()
|
||||||
@@ -116,7 +118,7 @@ async def process_nickname(message: Message, state: FSMContext):
|
|||||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
|
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text)
|
||||||
async def process_club_card(message: Message, state: FSMContext):
|
async def process_club_card(message: Message, state: FSMContext):
|
||||||
"""Обработка номера клубной карты"""
|
"""Обработка номера клубной карты"""
|
||||||
club_card_number = message.text.strip()
|
club_card_number = message.text.strip()
|
||||||
@@ -143,7 +145,7 @@ async def process_club_card(message: Message, state: FSMContext):
|
|||||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text)
|
||||||
async def process_phone(message: Message, state: FSMContext):
|
async def process_phone(message: Message, state: FSMContext):
|
||||||
"""Обработка номера телефона"""
|
"""Обработка номера телефона"""
|
||||||
phone_input = message.text.strip()
|
phone_input = message.text.strip()
|
||||||
@@ -196,7 +198,7 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async def cmd_test_start(message: Message):
|
|||||||
"""Тестовая команда /test_start (регистронезависимо)"""
|
"""Тестовая команда /test_start (регистронезависимо)"""
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
first_name = message.from_user.first_name
|
first_name = message.from_user.first_name
|
||||||
is_admin_user = is_admin(user_id)
|
is_admin_user = await is_admin(user_id)
|
||||||
|
|
||||||
welcome_text = f"👋 Привет, {first_name}!\n\n"
|
welcome_text = f"👋 Привет, {first_name}!\n\n"
|
||||||
welcome_text += "🎯 Это тестовая версия команды /start\n\n"
|
welcome_text += "🎯 Это тестовая версия команды /start\n\n"
|
||||||
@@ -51,7 +51,7 @@ async def cmd_test_start(message: Message):
|
|||||||
@test_router.message(CaseInsensitiveCommand("test_admin"))
|
@test_router.message(CaseInsensitiveCommand("test_admin"))
|
||||||
async def cmd_test_admin(message: Message):
|
async def cmd_test_admin(message: Message):
|
||||||
"""Тестовая команда /test_admin (регистронезависимо)"""
|
"""Тестовая команда /test_admin (регистронезависимо)"""
|
||||||
if not is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ async def back_to_main_handler(callback: CallbackQuery):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
is_admin_user = is_admin(user_id)
|
is_admin_user = await is_admin(user_id)
|
||||||
|
|
||||||
text = f"🏠 Главное меню\n\nВаш ID: {user_id}\n"
|
text = f"🏠 Главное меню\n\nВаш ID: {user_id}\n"
|
||||||
text += f"Статус: {'👑 Администратор' if is_admin_user else '👤 Пользователь'}"
|
text += f"Статус: {'👑 Администратор' if is_admin_user else '👤 Пользователь'}"
|
||||||
@@ -107,4 +107,4 @@ async def back_to_main_handler(callback: CallbackQuery):
|
|||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
)
|
)
|
||||||
|
|||||||
49
src/middlewares/access.py
Normal file
49
src/middlewares/access.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Check every staff message/callback, including unfinished FSM dialogs."""
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
|
from src.core.access import current_role, get_role
|
||||||
|
|
||||||
|
|
||||||
|
class AccessMiddleware(BaseMiddleware):
|
||||||
|
def __init__(self, *, allow_cashier=False, public_handlers=()):
|
||||||
|
self.allow_cashier = allow_cashier
|
||||||
|
self.public_handlers = set(public_handlers)
|
||||||
|
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
selected = data.get("handler")
|
||||||
|
if selected and selected.callback.__name__ in self.public_handlers:
|
||||||
|
return await handler(event, data)
|
||||||
|
actor = event.from_user
|
||||||
|
role = await get_role(actor.id) if actor else "user"
|
||||||
|
allowed = {"super_admin", "admin"}
|
||||||
|
if self.allow_cashier:
|
||||||
|
allowed.add("cashier")
|
||||||
|
if role not in allowed:
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
|
else:
|
||||||
|
await event.answer("❌ Недостаточно прав")
|
||||||
|
return
|
||||||
|
token = current_role.set((actor.id, role))
|
||||||
|
try:
|
||||||
|
return await handler(event, data)
|
||||||
|
finally:
|
||||||
|
current_role.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAccessMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
from src.core.access import is_admin
|
||||||
|
from src.core.chat_services import ChatPermissionService
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
admin = await is_admin(event.from_user.id)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
allowed, reason = await ChatPermissionService.can_send_message(session, event.from_user.id, is_admin=admin)
|
||||||
|
if not allowed:
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer(reason, show_alert=True)
|
||||||
|
else:
|
||||||
|
await event.answer(reason)
|
||||||
|
return
|
||||||
|
return await handler(event, data)
|
||||||
@@ -39,7 +39,6 @@ class ActivityMiddleware(BaseMiddleware):
|
|||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Обновляем активность
|
# Обновляем активность
|
||||||
await ActivityService.update_user_activity(session, telegram_id)
|
|
||||||
|
|
||||||
# Проверяем, не был ли пользователь заблокирован за неактивность
|
# Проверяем, не был ли пользователь заблокирован за неактивность
|
||||||
# Если был - реактивируем
|
# Если был - реактивируем
|
||||||
|
|||||||
38
src/middlewares/runtime.py
Normal file
38
src/middlewares/runtime.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Keep sensitive dialogs private and report unexpected failures safely."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateDialogMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
message = event if isinstance(event, Message) else getattr(event, "message", None)
|
||||||
|
if message and message.chat.type != "private":
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer("Откройте личный диалог с ботом", show_alert=True)
|
||||||
|
return
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
try:
|
||||||
|
return await handler(event, data)
|
||||||
|
except TelegramBadRequest as error:
|
||||||
|
if "message is not modified" in str(error):
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer()
|
||||||
|
return
|
||||||
|
logger.exception("Telegram rejected a bot response")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Update handler failed")
|
||||||
|
message = event.message if isinstance(event, CallbackQuery) else event
|
||||||
|
if message:
|
||||||
|
try:
|
||||||
|
await message.answer("Не удалось выполнить действие. Попробуйте ещё раз или отправьте /cancel.")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to deliver error response")
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete, update, func
|
from sqlalchemy import select, delete, update, func
|
||||||
from ..core.models import User, Lottery, Participation, Winner
|
from ..core.models import User, Lottery, Participation, Winner, WinnerVerification
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
@@ -219,6 +219,7 @@ class AdminUtils:
|
|||||||
"""Удалить розыгрыш (со всеми связанными данными)"""
|
"""Удалить розыгрыш (со всеми связанными данными)"""
|
||||||
try:
|
try:
|
||||||
# Удаляем победителей
|
# Удаляем победителей
|
||||||
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(select(Winner.id).where(Winner.lottery_id == lottery_id))))
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(Winner).where(Winner.lottery_id == lottery_id)
|
delete(Winner).where(Winner.lottery_id == lottery_id)
|
||||||
)
|
)
|
||||||
@@ -320,6 +321,7 @@ class AdminUtils:
|
|||||||
|
|
||||||
for lottery_id in lottery_ids:
|
for lottery_id in lottery_ids:
|
||||||
# Удаляем победителей
|
# Удаляем победителей
|
||||||
|
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(select(Winner.id).where(Winner.lottery_id == lottery_id))))
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
delete(Winner).where(Winner.lottery_id == lottery_id)
|
delete(Winner).where(Winner.lottery_id == lottery_id)
|
||||||
)
|
)
|
||||||
@@ -420,4 +422,4 @@ class ReportGenerator:
|
|||||||
report += f"{i}. {name}\n"
|
report += f"{i}. {name}\n"
|
||||||
report += f" Участий: {participations} | Побед: {wins}\n\n"
|
report += f" Участий: {participations} | Побед: {wins}\n\n"
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|||||||
55
src/utils/delivery.py
Normal file
55
src/utils/delivery.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Bound background Telegram delivery so interactive replies retain capacity."""
|
||||||
|
import asyncio
|
||||||
|
from functools import wraps
|
||||||
|
import time
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter
|
||||||
|
|
||||||
|
_limiters = WeakKeyDictionary()
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryLimiter:
|
||||||
|
def __init__(self):
|
||||||
|
self.slots = asyncio.Semaphore(8)
|
||||||
|
self.pace = asyncio.Lock()
|
||||||
|
self.next_send = 0.0
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
await self.slots.acquire()
|
||||||
|
try:
|
||||||
|
async with self.pace:
|
||||||
|
await asyncio.sleep(max(0, self.next_send - time.monotonic()))
|
||||||
|
self.next_send = time.monotonic() + 0.05
|
||||||
|
except BaseException:
|
||||||
|
self.slots.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
self.slots.release()
|
||||||
|
|
||||||
|
|
||||||
|
def limiter():
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if loop not in _limiters:
|
||||||
|
_limiters[loop] = DeliveryLimiter()
|
||||||
|
return _limiters[loop]
|
||||||
|
|
||||||
|
|
||||||
|
def background_delivery(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapped(*args, **kwargs):
|
||||||
|
async with limiter():
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@background_delivery
|
||||||
|
async def send_background(bot, **kwargs):
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
return await bot.send_message(**kwargs)
|
||||||
|
except TelegramRetryAfter as error:
|
||||||
|
if attempt:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(error.retry_after)
|
||||||
7
src/utils/errors.py
Normal file
7
src/utils/errors.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Keep unexpected database and transport details out of Telegram responses."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
def public_error(error):
|
||||||
|
logging.getLogger(__name__).error("Operation failed", exc_info=(type(error), error, error.__traceback__))
|
||||||
|
return "Не удалось выполнить действие. Повторите попытку позже."
|
||||||
@@ -16,123 +16,39 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int):
|
async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int):
|
||||||
"""
|
from src.core.database import async_session_maker
|
||||||
Асинхронно отправить уведомления победителям с кнопкой подтверждения.
|
from src.utils.delivery import send_background
|
||||||
Вызывается после проведения розыгрыша.
|
from sqlalchemy import update
|
||||||
|
|
||||||
Args:
|
|
||||||
bot: Экземпляр бота для отправки сообщений
|
|
||||||
session: Сессия БД
|
|
||||||
lottery_id: ID розыгрыша
|
|
||||||
"""
|
|
||||||
# Получаем информацию о розыгрыше
|
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
if not lottery:
|
if not lottery:
|
||||||
logger.error(f"Розыгрыш {lottery_id} не найден")
|
|
||||||
return
|
return
|
||||||
|
winners = await LotteryService.get_winners(session, lottery_id)
|
||||||
# Получаем всех победителей из БД
|
deliveries = []
|
||||||
winners_result = await session.execute(
|
|
||||||
select(Winner).where(Winner.lottery_id == lottery_id)
|
|
||||||
)
|
|
||||||
winners = winners_result.scalars().all()
|
|
||||||
|
|
||||||
logger.info(f"Найдено {len(winners)} победителей для розыгрыша {lottery_id}")
|
|
||||||
|
|
||||||
for winner in winners:
|
for winner in winners:
|
||||||
try:
|
if winner.is_notified:
|
||||||
# Если у победителя есть account_number, ищем владельца
|
|
||||||
if winner.account_number:
|
|
||||||
owner = await AccountService.get_account_owner(session, winner.account_number)
|
|
||||||
|
|
||||||
if owner and owner.telegram_id:
|
|
||||||
# Создаем токен верификации
|
|
||||||
verification = await WinnerNotificationService.create_verification_token(
|
|
||||||
session,
|
|
||||||
winner.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Формируем сообщение с кнопкой подтверждения
|
|
||||||
message = (
|
|
||||||
f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n"
|
|
||||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
|
||||||
f"🏆 Место: {winner.place}\n"
|
|
||||||
f"🎁 Приз: {winner.prize}\n"
|
|
||||||
f"💳 **Выигрышный счет: {winner.account_number}**\n\n"
|
|
||||||
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
|
||||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n"
|
|
||||||
f"Если вы не подтвердите в течение 24 часов, "
|
|
||||||
f"приз будет разыгран заново.\n\n"
|
|
||||||
f"ℹ️ Если у вас несколько выигрышных счетов, "
|
|
||||||
f"подтвердите каждый из них отдельно."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создаем кнопку подтверждения с указанием счета
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
text=f"✅ Подтвердить счет {winner.account_number}",
|
|
||||||
callback_data=f"confirm_win_{winner.id}"
|
|
||||||
)],
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
text="📞 Связаться с администратором",
|
|
||||||
url=f"tg://user?id={ADMIN_IDS[0]}"
|
|
||||||
)]
|
|
||||||
])
|
|
||||||
|
|
||||||
# Отправляем уведомление с кнопкой
|
|
||||||
await bot.send_message(
|
|
||||||
owner.telegram_id,
|
|
||||||
message,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Отмечаем, что уведомление отправлено
|
|
||||||
winner.is_notified = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
|
|
||||||
|
|
||||||
# Если победитель - обычный пользователь (старая система)
|
|
||||||
elif winner.user_id:
|
|
||||||
user_result = await session.execute(
|
|
||||||
select(User).where(User.id == winner.user_id)
|
|
||||||
)
|
|
||||||
user = user_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user and user.telegram_id:
|
|
||||||
message = (
|
|
||||||
f"🎉 Поздравляем! Вы выиграли!\n\n"
|
|
||||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
|
||||||
f"🏆 Место: {winner.place}\n"
|
|
||||||
f"🎁 Приз: {winner.prize}\n\n"
|
|
||||||
f"Свяжитесь с администратором для получения приза."
|
|
||||||
)
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(
|
|
||||||
text="📞 Связаться с администратором",
|
|
||||||
url=f"tg://user?id={ADMIN_IDS[0]}"
|
|
||||||
)]
|
|
||||||
])
|
|
||||||
|
|
||||||
await bot.send_message(
|
|
||||||
user.telegram_id,
|
|
||||||
message,
|
|
||||||
reply_markup=keyboard
|
|
||||||
)
|
|
||||||
|
|
||||||
winner.is_notified = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Ошибка при отправке уведомления победителю {winner.id}: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
owner = winner.user
|
||||||
logger.info(f"Завершена отправка уведомлений для розыгрыша {lottery_id}")
|
if not owner and winner.account_number:
|
||||||
|
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||||
|
if owner:
|
||||||
|
deliveries.append((winner.id, owner.telegram_id,
|
||||||
|
f"🎉 Поздравляем! Вы выиграли!\n\nРозыгрыш: {lottery.title}\n"
|
||||||
|
f"Место: {winner.place}\nПриз: {winner.prize}\n"
|
||||||
|
f"Счет: {winner.account_number or 'участие по Telegram ID'}\n\n"
|
||||||
|
"Подтвердите выигрыш в течение 24 часов."))
|
||||||
|
# Release the connection before any Telegram I/O.
|
||||||
|
await session.commit()
|
||||||
|
sent = 0
|
||||||
|
for winner_id, chat_id, text in deliveries:
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(
|
||||||
|
text="✅ Подтвердить выигрыш", callback_data=f"confirm_win_{winner_id}")]])
|
||||||
|
try:
|
||||||
|
await send_background(bot, chat_id=chat_id, text=text, reply_markup=keyboard)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not notify winner %s", winner_id)
|
||||||
|
continue
|
||||||
|
async with async_session_maker() as status_session:
|
||||||
|
await status_session.execute(update(Winner).where(Winner.id == winner_id).values(is_notified=True))
|
||||||
|
await status_session.commit()
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|||||||
69
src/utils/spreadsheets.py
Normal file
69
src/utils/spreadsheets.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Bounded Excel parsing and formula-safe exports, run outside the event loop."""
|
||||||
|
from io import BytesIO
|
||||||
|
from itertools import islice
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
|
MAX_FILE_BYTES = 5 * 1024 * 1024
|
||||||
|
MAX_ROWS = 10000
|
||||||
|
HEADERS = ["Telegram ID", "Username", "Имя", "Фамилия", "Никнейм", "Телефон", "Клубная карта",
|
||||||
|
"Зарегистрирован", "Админ", "Код верификации", "Дата создания", "Последняя активность", "Заблокирован в чате"]
|
||||||
|
|
||||||
|
|
||||||
|
def export_users(users):
|
||||||
|
workbook = Workbook(write_only=True)
|
||||||
|
sheet = workbook.create_sheet("Пользователи")
|
||||||
|
sheet.append(HEADERS)
|
||||||
|
from openpyxl.cell import WriteOnlyCell
|
||||||
|
for user in users:
|
||||||
|
row = [user.telegram_id, user.username, user.first_name, user.last_name, user.nickname, user.phone,
|
||||||
|
user.club_card_number, "Да" if user.is_registered else "Нет", "Да" if user.is_admin else "Нет",
|
||||||
|
user.verification_code, user.created_at.isoformat() if user.created_at else "",
|
||||||
|
user.last_activity.isoformat() if user.last_activity else "", "Да" if user.is_chat_banned else "Нет"]
|
||||||
|
cells = []
|
||||||
|
for value in row:
|
||||||
|
cell = WriteOnlyCell(sheet, value=value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
cell.data_type = "s" # User text such as '=HYPERLINK(...)' must never become a formula.
|
||||||
|
cells.append(cell)
|
||||||
|
sheet.append(cells)
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def read_users_xlsx(content):
|
||||||
|
if len(content) > MAX_FILE_BYTES:
|
||||||
|
raise ValueError("Файл должен быть не больше 5 МБ")
|
||||||
|
with ZipFile(BytesIO(content)) as archive:
|
||||||
|
if sum(info.file_size for info in archive.infolist()) > 64 * 1024 * 1024:
|
||||||
|
raise ValueError("Слишком большой размер распакованного XLSX")
|
||||||
|
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True, keep_links=False)
|
||||||
|
try:
|
||||||
|
sheet = workbook.active
|
||||||
|
if sheet.max_column and sheet.max_column > 32:
|
||||||
|
raise ValueError("В файле должно быть не более 32 столбцов")
|
||||||
|
rows = list(islice(sheet.iter_rows(values_only=True, max_col=32), MAX_ROWS + 2))
|
||||||
|
if len(rows) > MAX_ROWS + 1:
|
||||||
|
raise ValueError("В файле должно быть не более 10000 пользователей")
|
||||||
|
if len(rows) < 2 or "Telegram ID" not in rows[0]:
|
||||||
|
raise ValueError("Нет данных или колонки Telegram ID")
|
||||||
|
headers = rows[0]
|
||||||
|
mapping = {"Username": "username", "Имя": "first_name", "Фамилия": "last_name", "Никнейм": "nickname",
|
||||||
|
"Телефон": "phone", "Клубная карта": "club_card_number", "Зарегистрирован": "is_registered",
|
||||||
|
"Код верификации": "verification_code"}
|
||||||
|
result = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
if not any(value is not None for value in row):
|
||||||
|
continue
|
||||||
|
record = {"telegram_id": row[headers.index("Telegram ID")]}
|
||||||
|
for header, field in mapping.items():
|
||||||
|
if header in headers:
|
||||||
|
value = row[headers.index(header)]
|
||||||
|
if value is not None:
|
||||||
|
record[field] = value in ("Да", "Yes", "True", True, 1) if field == "is_registered" else str(value)
|
||||||
|
result.append(record)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
36
src/utils/telegram_messages.py
Normal file
36
src/utils/telegram_messages.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Copy user messages without losing Telegram entities, including custom emoji."""
|
||||||
|
from aiogram.types import Message, MessageEntity
|
||||||
|
|
||||||
|
|
||||||
|
def utf16_length(text: str) -> int:
|
||||||
|
"""Telegram entity offsets count UTF-16 code units, not Python characters."""
|
||||||
|
return len(text.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
async def copy_preserving_entities(message: Message, recipient_id: int, sender_name: str | None = None):
|
||||||
|
if sender_name is None:
|
||||||
|
# Native copy keeps the original body/caption and its entities unchanged.
|
||||||
|
return await message.copy_to(recipient_id)
|
||||||
|
|
||||||
|
prefix = "📨 "
|
||||||
|
header = f"{prefix}{sender_name}:\n\n"
|
||||||
|
header_entities = [MessageEntity(type="bold", offset=utf16_length(prefix),
|
||||||
|
length=utf16_length(sender_name + ":"))]
|
||||||
|
offset = utf16_length(header)
|
||||||
|
has_caption = any((message.photo, message.video, message.document,
|
||||||
|
message.animation, message.audio, message.voice))
|
||||||
|
body = message.text if message.text is not None else (message.caption or "")
|
||||||
|
original = message.entities if message.text is not None else message.caption_entities
|
||||||
|
entities = header_entities + [entity.model_copy(update={"offset": entity.offset + offset})
|
||||||
|
for entity in original or ()]
|
||||||
|
limit = 4096 if message.text is not None else 1024
|
||||||
|
if (message.text is not None or has_caption) and offset + utf16_length(body) <= limit:
|
||||||
|
if message.text is not None:
|
||||||
|
return await message.bot.send_message(recipient_id, header + body,
|
||||||
|
entities=entities, parse_mode=None)
|
||||||
|
return await message.copy_to(recipient_id, caption=header + body,
|
||||||
|
caption_entities=entities, parse_mode=None)
|
||||||
|
|
||||||
|
# Do not trim a long body/caption: send the header separately and copy the original.
|
||||||
|
await message.bot.send_message(recipient_id, header, entities=header_entities, parse_mode=None)
|
||||||
|
return await message.copy_to(recipient_id)
|
||||||
27
tests/conftest.py
Normal file
27
tests/conftest.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""All tests use disposable databases and a synthetic Telegram token."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
_temporary = tempfile.TemporaryDirectory(prefix="lottery-tests-")
|
||||||
|
os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL") or (
|
||||||
|
"sqlite+aiosqlite:///" + (Path(_temporary.name) / "test.db").as_posix()
|
||||||
|
)
|
||||||
|
os.environ["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||||
|
os.environ["ADMIN_IDS"] = "900001"
|
||||||
|
os.environ["CASHIER_IDS"] = "900002"
|
||||||
|
os.environ["REDIS_URL"] = ""
|
||||||
|
|
||||||
|
from src.core.database import Base, engine, async_session_maker
|
||||||
|
from src.core import models
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def database():
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.drop_all)
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
yield async_session_maker
|
||||||
|
await engine.dispose()
|
||||||
120
tests/test_access_runtime.py
Normal file
120
tests/test_access_runtime.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiogram import Bot, Dispatcher, Router, F
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import Message, Chat, User as TelegramUser, CallbackQuery, Update
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.core.access import is_admin, is_staff
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User, Winner
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.middlewares.access import AccessMiddleware
|
||||||
|
from src.controllers.bot_controller import BotController
|
||||||
|
|
||||||
|
|
||||||
|
async def test_assigned_admin_and_cashier_permissions_update_immediately():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
admin = await UserService.get_or_create_user(session, 301)
|
||||||
|
admin.is_admin = True
|
||||||
|
cashier = await UserService.get_or_create_user(session, 302)
|
||||||
|
cashier.is_cashier = True
|
||||||
|
await session.commit()
|
||||||
|
assert await is_admin(301)
|
||||||
|
assert await is_staff(302) and not await is_admin(302)
|
||||||
|
admin.is_admin = False
|
||||||
|
await session.commit()
|
||||||
|
assert not await is_admin(301)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role,allow_cashier,permitted", [("user", False, False), ("user", True, False),
|
||||||
|
("cashier", False, False), ("cashier", True, True), ("admin", False, True)])
|
||||||
|
async def test_every_staff_fsm_step_checks_permissions(role, allow_cashier, permitted):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, 303)
|
||||||
|
user.is_admin = role == "admin"
|
||||||
|
user.is_cashier = role == "cashier"
|
||||||
|
await session.commit()
|
||||||
|
handler = AsyncMock()
|
||||||
|
event = SimpleNamespace(from_user=SimpleNamespace(id=303), answer=AsyncMock())
|
||||||
|
await AccessMiddleware(allow_cashier=allow_cashier)(handler, event, {})
|
||||||
|
assert handler.await_count == int(permitted)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_callback_main_menu_uses_clicker_identity():
|
||||||
|
from main import back_to_main_handler
|
||||||
|
import main
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
controller = SimpleNamespace(handle_start=AsyncMock())
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_controller():
|
||||||
|
yield controller
|
||||||
|
old = main.get_controller
|
||||||
|
main.get_controller = get_controller
|
||||||
|
try:
|
||||||
|
actor = TelegramUser(id=304, is_bot=False, first_name="Human")
|
||||||
|
event = SimpleNamespace(from_user=actor, message=object(), answer=AsyncMock())
|
||||||
|
state = SimpleNamespace(clear=AsyncMock())
|
||||||
|
await back_to_main_handler(event, state)
|
||||||
|
controller.handle_start.assert_awaited_once_with(event.message, actor=actor)
|
||||||
|
state.clear.assert_awaited_once()
|
||||||
|
finally:
|
||||||
|
main.get_controller = old
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slow_user_does_not_block_other_user_and_same_user_is_serialized():
|
||||||
|
storage = MemoryStorage()
|
||||||
|
dispatcher = Dispatcher(storage=storage, events_isolation=SimpleEventIsolation())
|
||||||
|
router = Router()
|
||||||
|
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS")
|
||||||
|
first_started, release_first, other_finished = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
seen = []
|
||||||
|
@router.message(F.text)
|
||||||
|
async def receive(message: Message, state: FSMContext):
|
||||||
|
if message.text == "slow":
|
||||||
|
first_started.set()
|
||||||
|
await release_first.wait()
|
||||||
|
data = await state.get_data()
|
||||||
|
seen.append((message.from_user.id, data.get("count", 0)))
|
||||||
|
await state.update_data(count=data.get("count", 0) + 1)
|
||||||
|
if message.from_user.id == 402:
|
||||||
|
other_finished.set()
|
||||||
|
dispatcher.include_router(router)
|
||||||
|
def update(number, user_id, text):
|
||||||
|
return Update(update_id=number, message=Message(message_id=number, date=datetime.now(timezone.utc),
|
||||||
|
chat=Chat(id=user_id, type="private"), from_user=TelegramUser(id=user_id, is_bot=False, first_name="Actor"), text=text))
|
||||||
|
slow = asyncio.create_task(dispatcher.feed_update(bot, update(1, 401, "slow")))
|
||||||
|
await asyncio.wait_for(first_started.wait(), 1)
|
||||||
|
same = asyncio.create_task(dispatcher.feed_update(bot, update(2, 401, "next")))
|
||||||
|
other = asyncio.create_task(dispatcher.feed_update(bot, update(3, 402, "fast")))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(other_finished.wait(), 1)
|
||||||
|
assert not same.done()
|
||||||
|
finally:
|
||||||
|
release_first.set()
|
||||||
|
await asyncio.gather(slow, same, other)
|
||||||
|
await bot.session.close()
|
||||||
|
await dispatcher.fsm.close()
|
||||||
|
assert seen == [(402, 0), (401, 0), (401, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_owner_cannot_confirm_user_only_prize():
|
||||||
|
from src.handlers.redraw_handlers import confirm_winner_callback
|
||||||
|
from src.core.services import LotteryService
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
owner = await UserService.get_or_create_user(session, 601)
|
||||||
|
draw = await LotteryService.create_lottery(session, "Draw", "", ["Prize"], owner.id)
|
||||||
|
winner = Winner(lottery_id=draw.id, user_id=owner.id, place=1)
|
||||||
|
session.add(winner)
|
||||||
|
await session.commit()
|
||||||
|
winner_id = winner.id
|
||||||
|
callback = SimpleNamespace(data=f"confirm_win_{winner_id}", from_user=SimpleNamespace(id=602), answer=AsyncMock())
|
||||||
|
await confirm_winner_callback(callback)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert not (await session.get(Winner, winner_id)).is_claimed
|
||||||
|
callback.answer.assert_awaited_once()
|
||||||
97
tests/test_chat_delivery.py
Normal file
97
tests/test_chat_delivery.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter
|
||||||
|
from aiogram.methods import CopyMessage
|
||||||
|
from aiogram.types import Message, Chat, User as TelegramUser
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.core.broadcast_services import BroadcastService
|
||||||
|
from src.core.chat_services import ChatMessageService
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import ChatMessage, BlockedUser
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.handlers.chat_handlers import _is_message_processed, _processed_messages, _copy_with_sender
|
||||||
|
from test_dispatcher import TelegramStub
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_message_numbers_from_different_users_are_independent():
|
||||||
|
_processed_messages.clear()
|
||||||
|
assert not _is_message_processed(101, 7)
|
||||||
|
assert not _is_message_processed(102, 7)
|
||||||
|
assert _is_message_processed(101, 7)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_moderation_lookup_includes_receiving_chat():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
first = await UserService.get_or_create_user(session, 101)
|
||||||
|
second = await UserService.get_or_create_user(session, 102)
|
||||||
|
one = ChatMessage(user_id=first.id, telegram_message_id=7, message_type="text",
|
||||||
|
forwarded_message_ids={"900001": 41})
|
||||||
|
two = ChatMessage(user_id=second.id, telegram_message_id=7, message_type="text",
|
||||||
|
forwarded_message_ids={"900002": 41})
|
||||||
|
session.add_all([one, two])
|
||||||
|
await session.commit()
|
||||||
|
assert (await ChatMessageService.get_message_by_telegram_id(session, 7, chat_id=101)).id == one.id
|
||||||
|
assert (await ChatMessageService.get_message_by_telegram_id(session, 41, chat_id=900002)).id == two.id
|
||||||
|
assert await ChatMessageService.get_message_by_telegram_id(session, 41, chat_id=123) is None
|
||||||
|
assert await ChatMessageService.get_message_by_telegram_id(session, 7) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_preserves_literal_sender_and_text_and_keeps_max_length_messages():
|
||||||
|
stub = TelegramStub()
|
||||||
|
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
|
||||||
|
msg = Message(message_id=2, date=datetime.now(timezone.utc), chat=Chat(id=101, type="private"),
|
||||||
|
from_user=TelegramUser(id=101, first_name="<name>", is_bot=False), text="<b>literal</b> & value").as_(bot)
|
||||||
|
assert await _copy_with_sender(msg, 102, "<name>") == 100
|
||||||
|
assert "<name>" in stub.calls[0].text
|
||||||
|
assert "<b>literal</b> & value" in stub.calls[0].text
|
||||||
|
assert stub.calls[0].parse_mode is None
|
||||||
|
assert len(stub.calls[0].entities) == 1 # Only the explicit sender header is formatted.
|
||||||
|
stub.calls.clear()
|
||||||
|
long_message = msg.model_copy(update={"text": "x" * 4096}).as_(bot)
|
||||||
|
await _copy_with_sender(long_message, 102, "Sender")
|
||||||
|
assert [call.__api_method__ for call in stub.calls] == ["sendMessage", "copyMessage"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_parallel_broadcast_retries_do_not_deadlock_delivery_slots():
|
||||||
|
service = BroadcastService()
|
||||||
|
async def send(index):
|
||||||
|
message = SimpleNamespace(copy_to=AsyncMock(side_effect=[
|
||||||
|
TelegramRetryAfter(method=CopyMessage(chat_id=index, from_chat_id=1, message_id=1),
|
||||||
|
message="Retry", retry_after=0), SimpleNamespace(message_id=1)]))
|
||||||
|
result = await service.send_message_to_user(None, SimpleNamespace(telegram_id=index), message)
|
||||||
|
assert message.copy_to.await_count == 2
|
||||||
|
return result
|
||||||
|
results = await asyncio.wait_for(asyncio.gather(*(send(index) for index in range(8))), timeout=8)
|
||||||
|
assert all(success for success, _ in results)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_parallel_delivery_failures_create_one_blocked_user_record():
|
||||||
|
service = BroadcastService()
|
||||||
|
async def mark():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await service.mark_user_blocked(session, 200, "blocked_bot", "test")
|
||||||
|
await asyncio.gather(*(mark() for _ in range(8)))
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
record = (await session.scalars(select(BlockedUser))).one()
|
||||||
|
assert record.telegram_id == 200
|
||||||
|
assert record.attempt_count == 8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inactivity_job_preserves_existing_delivery_block_and_continues():
|
||||||
|
from datetime import timedelta
|
||||||
|
from src.core.activity_service import ActivityService
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
for number in (201, 202):
|
||||||
|
user = await UserService.get_or_create_user(session, number)
|
||||||
|
user.is_registered = True
|
||||||
|
user.last_activity = datetime.now(timezone.utc) - timedelta(days=31)
|
||||||
|
await session.commit()
|
||||||
|
await BroadcastService().mark_user_blocked(session, 201, "blocked_bot", "test")
|
||||||
|
assert await ActivityService.mark_inactive_users(session) == 1
|
||||||
|
assert await ActivityService.mark_inactive_users(session) == 0
|
||||||
|
assert await session.scalar(select(BlockedUser.error_type).where(BlockedUser.telegram_id == 201)) == "blocked_bot"
|
||||||
123
tests/test_concurrency.py
Normal file
123
tests/test_concurrency.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User, Lottery, Participation, Winner
|
||||||
|
from src.core.services import UserService, LotteryService, ParticipationService
|
||||||
|
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||||
|
from src.handlers.account_services import AccountParticipationService
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(prizes=2):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, 101, first_name="Client")
|
||||||
|
user.club_card_number = "1234"
|
||||||
|
user.verification_code = "AABBCCDD"
|
||||||
|
user.is_registered = True
|
||||||
|
await session.commit()
|
||||||
|
lottery = await LotteryService.create_lottery(session, "Prizes", "Draw", ["Prize"] * prizes, user.id)
|
||||||
|
return user.id, lottery.id
|
||||||
|
|
||||||
|
|
||||||
|
async def independent(call, *args):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
return await call(session, *args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_user_upsert_preserves_one_user_and_metadata():
|
||||||
|
users = await asyncio.gather(*(independent(UserService.get_or_create_user, 102, "client", "Name") for _ in range(12)))
|
||||||
|
assert len({user.id for user in users}) == 1
|
||||||
|
user = await independent(UserService.get_or_create_user, 102)
|
||||||
|
assert user.first_name == "Name"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_cashiers_add_same_ticket_once():
|
||||||
|
_, lottery_id = await seed()
|
||||||
|
results = await asyncio.gather(*(independent(AccountParticipationService.add_account_to_lottery,
|
||||||
|
lottery_id, "11-22-33-44-55-66-77") for _ in range(12)))
|
||||||
|
assert sum(result["success"] for result in results) == 1
|
||||||
|
assert await independent(ParticipationService.get_participants_count, lottery_id) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_admins_add_same_user_once():
|
||||||
|
user_id, lottery_id = await seed()
|
||||||
|
results = await asyncio.gather(*(independent(ParticipationService.add_participant, lottery_id, user_id) for _ in range(8)))
|
||||||
|
assert sum(results) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_admins_conduct_one_draw_and_keep_ticket_numbers():
|
||||||
|
_, lottery_id = await seed()
|
||||||
|
for account in ["11-22-33-44-55-66-77", "22-33-44-55-66-77-88"]:
|
||||||
|
await independent(AccountService.create_account, "1234", account)
|
||||||
|
await independent(AccountParticipationService.add_account_to_lottery, lottery_id, account)
|
||||||
|
results = await asyncio.gather(*(independent(LotteryService.conduct_draw, lottery_id) for _ in range(6)))
|
||||||
|
assert sum(bool(result) for result in results) == 1
|
||||||
|
winners = await independent(LotteryService.get_winners, lottery_id)
|
||||||
|
assert {w.account_number for w in winners} == {"11-22-33-44-55-66-77", "22-33-44-55-66-77-88"}
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
lottery = await session.get(Lottery, lottery_id)
|
||||||
|
assert lottery.is_completed and not lottery.is_active and len(lottery.draw_results) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_cashiers_claim_one_prize_once():
|
||||||
|
user_id, lottery_id = await seed(prizes=1)
|
||||||
|
await independent(ParticipationService.add_participant, lottery_id, user_id)
|
||||||
|
await independent(LotteryService.conduct_draw, lottery_id)
|
||||||
|
results = await asyncio.gather(*(independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id) for _ in range(8)))
|
||||||
|
assert sum(result is not None for result in results) == 1
|
||||||
|
assert next(result for result in results if result is not None).claimed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_winning_accounts_can_be_claimed_separately():
|
||||||
|
_, lottery_id = await seed()
|
||||||
|
for account in ["11-22-33-44-55-66-77", "22-33-44-55-66-77-88"]:
|
||||||
|
await independent(AccountService.create_account, "1234", account)
|
||||||
|
await independent(AccountParticipationService.add_account_to_lottery, lottery_id, account)
|
||||||
|
await independent(LotteryService.conduct_draw, lottery_id)
|
||||||
|
one = await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id)
|
||||||
|
two = await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id)
|
||||||
|
assert one.id != two.id
|
||||||
|
assert await independent(WinnerNotificationService.verify_winner, "AABBCCDD", lottery_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closed_draw_rejects_all_participant_mutations():
|
||||||
|
user_id, lottery_id = await seed(1)
|
||||||
|
await independent(ParticipationService.add_participant, lottery_id, user_id)
|
||||||
|
await independent(LotteryService.conduct_draw, lottery_id)
|
||||||
|
result = await independent(AccountParticipationService.add_account_to_lottery, lottery_id, "11-22-33-44-55-66-77")
|
||||||
|
assert not result["success"]
|
||||||
|
assert not await independent(ParticipationService.remove_participant, lottery_id, user_id)
|
||||||
|
assert not await independent(ParticipationService.add_participant, lottery_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_account_error_does_not_poison_bulk_session():
|
||||||
|
await seed()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await AccountService.create_account(session, "1234", "11223344556677")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await AccountService.create_account(session, "1234", "11223344556677")
|
||||||
|
account = await AccountService.create_account(session, "1234", "22334455667788")
|
||||||
|
assert account.account_number == "22-33-44-55-66-77-88"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_database_unique_constraints_protect_external_writers():
|
||||||
|
user_id, lottery_id = await seed()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
session.add_all([Winner(lottery_id=lottery_id, user_id=user_id, place=1),
|
||||||
|
Winner(lottery_id=lottery_id, user_id=user_id, place=1)])
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await session.commit()
|
||||||
|
await session.rollback()
|
||||||
|
assert await session.scalar(select(func.count()).select_from(Winner)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_winner_json_is_persisted_and_participant_is_required():
|
||||||
|
user_id, lottery_id = await seed()
|
||||||
|
assert not await independent(LotteryService.set_manual_winner, lottery_id, 1, 101)
|
||||||
|
await independent(ParticipationService.add_participant, lottery_id, user_id)
|
||||||
|
assert await independent(LotteryService.set_manual_winner, lottery_id, 1, 101)
|
||||||
|
result = await independent(LotteryService.conduct_draw, lottery_id)
|
||||||
|
assert result[1]["is_manual"] and result[1]["user"].id == user_id
|
||||||
29
tests/test_deployment.py
Normal file
29
tests/test_deployment.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from scripts.build_release import build_release
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrations_have_one_head():
|
||||||
|
assert ScriptDirectory.from_config(Config("alembic.ini")).get_heads() == ["20260913_staff_concurrency"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_contains_runtime_and_migrations_but_no_secrets(tmp_path):
|
||||||
|
path = build_release(tmp_path / "lottery.tar.gz")
|
||||||
|
with tarfile.open(path) as archive:
|
||||||
|
names = archive.getnames()
|
||||||
|
assert "main.py" in names and "Dockerfile" in names
|
||||||
|
assert "migrations/versions/20260913_staff_concurrency.py" in names
|
||||||
|
assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_requires_success_and_never_deploys_pull_requests():
|
||||||
|
config = yaml.safe_load(Path(".drone.yml").read_text(encoding="utf-8"))
|
||||||
|
deploy = config["steps"][-1]
|
||||||
|
assert deploy["when"] == {"branch": ["master"], "event": ["push"], "status": ["success"]}
|
||||||
|
assert not any("||" in command for step in config["steps"] for command in step["commands"])
|
||||||
|
assert {step["name"] for step in config["steps"]} >= {"checks", "postgres-tests", "package", "deploy"}
|
||||||
90
tests/test_dispatcher.py
Normal file
90
tests/test_dispatcher.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.client.session.base import BaseSession
|
||||||
|
from aiogram.types import Update, Message, User as TelegramUser, Chat, CallbackQuery
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User
|
||||||
|
from src.core.services import UserService
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramStub(BaseSession):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def make_request(self, bot, method, timeout=None):
|
||||||
|
self.calls.append(method)
|
||||||
|
if method.__api_method__ in {"answerCallbackQuery", "deleteMessage"}:
|
||||||
|
return True
|
||||||
|
return Message(message_id=100, date=datetime.now(timezone.utc),
|
||||||
|
chat=Chat(id=int(getattr(method, "chat_id", 1)), type="private"),
|
||||||
|
from_user=TelegramUser(id=bot.id, is_bot=True, first_name="Bot"), text=getattr(method, "text", ""))
|
||||||
|
|
||||||
|
async def stream_content(self, url, headers=None, timeout=30, chunk_size=65536, raise_for_status=True):
|
||||||
|
yield b""
|
||||||
|
|
||||||
|
|
||||||
|
def event(user_id, text=None, callback_data=None):
|
||||||
|
actor = TelegramUser(id=user_id, is_bot=False, first_name="Human")
|
||||||
|
msg = Message(message_id=1, date=datetime.now(timezone.utc), chat=Chat(id=user_id, type="private"),
|
||||||
|
from_user=actor if text else TelegramUser(id=123456, is_bot=True, first_name="Bot"), text=text or "Menu")
|
||||||
|
if callback_data:
|
||||||
|
return Update(update_id=1, callback_query=CallbackQuery(id="test", from_user=actor, chat_instance="private",
|
||||||
|
message=msg, data=callback_data))
|
||||||
|
return Update(update_id=1, message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(update):
|
||||||
|
import main
|
||||||
|
if not main.dp.sub_routers:
|
||||||
|
main.configure_dispatcher()
|
||||||
|
stub = TelegramStub()
|
||||||
|
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
|
||||||
|
await main.dp.feed_update(bot, update)
|
||||||
|
return stub.calls
|
||||||
|
|
||||||
|
|
||||||
|
async def test_actual_back_button_never_registers_bot_as_user():
|
||||||
|
calls = await dispatch(event(701, callback_data="back_to_main"))
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
ids = (await session.scalars(select(User.telegram_id))).all()
|
||||||
|
assert ids == [701]
|
||||||
|
assert any((getattr(call, "text", "") or "").startswith("👋") for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_actual_cashier_menu_available_to_cashier_but_admin_menu_denied():
|
||||||
|
calls = await dispatch(event(900002, text="/cashier"))
|
||||||
|
assert any("💼 Касса" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
calls = await dispatch(event(900002, callback_data="admin_participants"))
|
||||||
|
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_actual_forged_account_callback_is_denied_before_mutation():
|
||||||
|
calls = await dispatch(event(702, callback_data="add_to_lottery_1"))
|
||||||
|
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_assigned_admin_can_open_participant_management():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, 703)
|
||||||
|
user.is_admin = True
|
||||||
|
await session.commit()
|
||||||
|
calls = await dispatch(event(703, callback_data="admin_participants"))
|
||||||
|
assert any(getattr(call, "reply_markup", None) for call in calls)
|
||||||
|
assert not any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_cancel_clears_state():
|
||||||
|
calls = await dispatch(event(704, text="/register"))
|
||||||
|
assert any("Никнейм" in (getattr(call, "text", "") or "") or "никнейм" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
import main
|
||||||
|
state = main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=704, user_id=704)
|
||||||
|
assert (await state.get_state()).endswith("waiting_for_nickname")
|
||||||
|
await dispatch(event(704, text="/cancel"))
|
||||||
|
assert await state.get_state() is None
|
||||||
33
tests/test_imports_exports.py
Normal file
33
tests/test_imports_exports.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User
|
||||||
|
from src.core.import_services import import_users
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.utils.spreadsheets import export_users, read_users_xlsx
|
||||||
|
|
||||||
|
|
||||||
|
async def test_excel_formulas_remain_literal_text():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, 501, first_name='=HYPERLINK("https://invalid.example")')
|
||||||
|
data = export_users([user])
|
||||||
|
workbook = load_workbook(BytesIO(data), data_only=False)
|
||||||
|
assert workbook.active.cell(2, 3).data_type == "s"
|
||||||
|
assert read_users_xlsx(data)[0]["first_name"] == user.first_name
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_duplicate_card_does_not_break_following_rows_or_elevate_roles():
|
||||||
|
added, updated, errors = await import_users([
|
||||||
|
dict(telegram_id=502, club_card_number="5678", is_registered=True, is_admin=True, is_cashier=True),
|
||||||
|
dict(telegram_id=503, club_card_number="5678"),
|
||||||
|
dict(telegram_id=504, club_card_number="6789"),
|
||||||
|
])
|
||||||
|
assert (added, updated, errors) == (2, 0, 1)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await session.scalar(select(User).where(User.telegram_id == 502))
|
||||||
|
assert not user.is_admin and not user.is_cashier and user.verification_code
|
||||||
|
assert await session.scalar(select(User.id).where(User.telegram_id == 504))
|
||||||
33
tests/test_migrations.py
Normal file
33
tests/test_migrations.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL", "").startswith("postgresql"), reason="requires disposable PostgreSQL")
|
||||||
|
@pytest.mark.parametrize("baseline", [None, "20260701_perf_indexes"])
|
||||||
|
async def test_fresh_migrations_and_schema_check(baseline):
|
||||||
|
url = make_url(os.environ["TEST_DATABASE_URL"])
|
||||||
|
temporary_database = "lottery_migration_test_" + uuid.uuid4().hex
|
||||||
|
admin_url = url.set(drivername="postgresql").render_as_string(hide_password=False)
|
||||||
|
connection = await asyncpg.connect(admin_url)
|
||||||
|
# Database name is generated here; the configured test database is never dropped.
|
||||||
|
await connection.execute(f'CREATE DATABASE "{temporary_database}"')
|
||||||
|
try:
|
||||||
|
environment = dict(os.environ, DATABASE_URL=url.set(database=temporary_database).render_as_string(hide_password=False))
|
||||||
|
def check(*command):
|
||||||
|
result = subprocess.run([sys.executable, *command], env=environment, capture_output=True, text=True, timeout=60)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
if baseline:
|
||||||
|
await asyncio.to_thread(check, "-m", "alembic", "upgrade", baseline)
|
||||||
|
await asyncio.to_thread(check, "-m", "alembic", "upgrade", "head")
|
||||||
|
await asyncio.to_thread(check, "-m", "alembic", "upgrade", "head")
|
||||||
|
await asyncio.to_thread(check, "scripts/check_schema.py")
|
||||||
|
finally:
|
||||||
|
await connection.execute(f'DROP DATABASE "{temporary_database}" WITH (FORCE)')
|
||||||
|
await connection.close()
|
||||||
@@ -123,13 +123,14 @@ async def test_display_types():
|
|||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
|
creator = await UserService.get_or_create_user(session, 990001, first_name="Test creator")
|
||||||
# Создаём розыгрыш
|
# Создаём розыгрыш
|
||||||
lottery = await LotteryService.create_lottery(
|
lottery = await LotteryService.create_lottery(
|
||||||
session,
|
session,
|
||||||
title="Тест отображения победителей",
|
title="Тест отображения победителей",
|
||||||
description="Розыгрыш для тестирования различных типов отображения",
|
description="Розыгрыш для тестирования различных типов отображения",
|
||||||
prizes=["Первый приз", "Второй приз"],
|
prizes=["Первый приз", "Второй приз"],
|
||||||
creator_id=1
|
creator_id=creator.id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Получаем пользователя со счётом
|
# Получаем пользователя со счётом
|
||||||
|
|||||||
165
tests/test_premium_emoji.py
Normal file
165
tests/test_premium_emoji.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.methods import CopyMessage
|
||||||
|
from aiogram.types import Chat, Message, MessageEntity, Update, User as TelegramUser
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.core.broadcast_services import BroadcastService
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.emoji_mapping_service import EmojiMappingService
|
||||||
|
from src.core.models import BlockedUser, EmojiMapping, P2PMessage
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.handlers.p2p_chat import handle_p2p_message
|
||||||
|
from src.utils.telegram_messages import copy_preserving_entities
|
||||||
|
from test_dispatcher import TelegramStub, dispatch, event
|
||||||
|
|
||||||
|
|
||||||
|
BODY = "😀 🎲 🎲 <b>"
|
||||||
|
FIRST_ID = "5368324170671202286"
|
||||||
|
SECOND_ID = "5368324170671202287"
|
||||||
|
|
||||||
|
|
||||||
|
def emoji_entities():
|
||||||
|
return [MessageEntity(type="bold", offset=3, length=5),
|
||||||
|
MessageEntity(type="custom_emoji", offset=3, length=2, custom_emoji_id=FIRST_ID),
|
||||||
|
MessageEntity(type="custom_emoji", offset=6, length=2, custom_emoji_id=SECOND_ID),
|
||||||
|
MessageEntity(type="text_link", offset=9, length=3, url="https://example.com")]
|
||||||
|
|
||||||
|
|
||||||
|
def source_message(bot, **values):
|
||||||
|
return Message(message_id=7, date=datetime.now(timezone.utc), chat=Chat(id=101, type="private"),
|
||||||
|
from_user=TelegramUser(id=101, is_bot=False, first_name="Sender"), **values).as_(bot)
|
||||||
|
|
||||||
|
|
||||||
|
def make_bot():
|
||||||
|
stub = TelegramStub()
|
||||||
|
return Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub,
|
||||||
|
default=DefaultBotProperties(parse_mode="Markdown")), stub
|
||||||
|
|
||||||
|
|
||||||
|
def assert_custom_emoji(text, entities):
|
||||||
|
custom = [entity for entity in entities if entity.type == "custom_emoji"]
|
||||||
|
assert [entity.custom_emoji_id for entity in custom] == [FIRST_ID, SECOND_ID]
|
||||||
|
assert [entity.extract_from(text) for entity in custom] == ["🎲", "🎲"]
|
||||||
|
link = next(entity for entity in entities if entity.type == "text_link")
|
||||||
|
assert link.extract_from(text) == "<b>"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_custom_emoji_and_nested_formatting_survive_utf16_header():
|
||||||
|
bot, stub = make_bot()
|
||||||
|
message = source_message(bot, text=BODY, entities=emoji_entities())
|
||||||
|
await copy_preserving_entities(message, 102, "Кассир 🧑💻 <name>")
|
||||||
|
sent = stub.calls[0]
|
||||||
|
assert sent.parse_mode is None
|
||||||
|
assert sent.text.endswith(BODY)
|
||||||
|
assert_custom_emoji(sent.text, sent.entities)
|
||||||
|
assert [entity.offset for entity in message.entities] == [3, 3, 6, 9]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("media", ["photo", "video", "document", "animation", "audio", "voice"])
|
||||||
|
async def test_custom_emoji_survive_media_caption(media):
|
||||||
|
bot, stub = make_bot()
|
||||||
|
value = dict(file_id="file", file_unique_id="unique", width=1, height=1, duration=1)
|
||||||
|
message = source_message(bot, caption=BODY, caption_entities=emoji_entities(),
|
||||||
|
**{media: [value] if media == "photo" else value})
|
||||||
|
await copy_preserving_entities(message, 102, "Отправитель 😀")
|
||||||
|
sent = stub.calls[0]
|
||||||
|
assert sent.__api_method__ == "copyMessage"
|
||||||
|
assert sent.parse_mode is None
|
||||||
|
assert_custom_emoji(sent.caption, sent.caption_entities)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caption", [False, True])
|
||||||
|
async def test_max_length_message_is_copied_without_trimming_emoji(caption):
|
||||||
|
bot, stub = make_bot()
|
||||||
|
limit = 1024 if caption else 4096
|
||||||
|
body = "x" * (limit - 2) + "🎲"
|
||||||
|
entities = [MessageEntity(type="custom_emoji", offset=limit - 2, length=2, custom_emoji_id=FIRST_ID)]
|
||||||
|
values = dict(caption=body, caption_entities=entities, document={"file_id": "file", "file_unique_id": "unique"}) if caption else dict(text=body, entities=entities)
|
||||||
|
message = source_message(bot, **values)
|
||||||
|
await copy_preserving_entities(message, 102, "Sender")
|
||||||
|
assert [method.__api_method__ for method in stub.calls] == ["sendMessage", "copyMessage"]
|
||||||
|
assert stub.calls[1].from_chat_id == 101 and stub.calls[1].message_id == 7
|
||||||
|
assert stub.calls[1].caption is None # Telegram copies the original caption and all its entities.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_p2p_delivers_custom_emoji_and_stores_literal_text():
|
||||||
|
bot, stub = make_bot()
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
sender = await UserService.get_or_create_user(session, 101, first_name="Sender")
|
||||||
|
recipient = await UserService.get_or_create_user(session, 102, first_name="Recipient")
|
||||||
|
recipient_id = recipient.id
|
||||||
|
state = SimpleNamespace(get_data=AsyncMock(return_value={"recipient_id": recipient_id, "recipient_telegram_id": 102}))
|
||||||
|
message = source_message(bot, text=BODY, entities=emoji_entities())
|
||||||
|
await handle_p2p_message(message, state)
|
||||||
|
delivery = next(call for call in stub.calls if getattr(call, "chat_id", None) == 102)
|
||||||
|
assert_custom_emoji(delivery.text, delivery.entities)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
saved = (await session.scalars(select(P2PMessage))).one()
|
||||||
|
assert saved.text == BODY
|
||||||
|
assert saved.recipient_message_id == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_direct_and_channel_broadcast_copy_original_custom_emoji_message():
|
||||||
|
bot, stub = make_bot()
|
||||||
|
message = source_message(bot, text=BODY, entities=emoji_entities())
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
admin = await UserService.get_or_create_user(session, 101)
|
||||||
|
recipient = await UserService.get_or_create_user(session, 102)
|
||||||
|
admin_id = admin.id
|
||||||
|
service = BroadcastService()
|
||||||
|
assert (await service.send_message_to_user(bot, recipient, message))[0]
|
||||||
|
assert await service.broadcast_to_channel(bot, message, -100123, admin_id)
|
||||||
|
assert [call.chat_id for call in stub.calls] == [102, -100123]
|
||||||
|
assert all(call.__api_method__ == "copyMessage" and call.from_chat_id == 101
|
||||||
|
and call.message_id == 7 and call.caption is None for call in stub.calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejected_custom_emoji_does_not_block_future_broadcasts():
|
||||||
|
message = SimpleNamespace(copy_to=AsyncMock(side_effect=[
|
||||||
|
TelegramBadRequest(method=CopyMessage(chat_id=102, from_chat_id=101, message_id=7),
|
||||||
|
message="CUSTOM_EMOJI_INVALID"), SimpleNamespace(message_id=100)]))
|
||||||
|
service = BroadcastService()
|
||||||
|
user = SimpleNamespace(telegram_id=102)
|
||||||
|
assert await service.send_message_to_user(None, user, message) == (False, "bad_request")
|
||||||
|
assert await service.send_message_to_user(None, user, message) == (True, None)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert (await session.scalars(select(BlockedUser))).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_admin_dialog_rejects_plain_emoji_and_registers_actual_id():
|
||||||
|
await dispatch(event(900001, text="/cancel"))
|
||||||
|
await dispatch(event(900001, text="/add_emoji"))
|
||||||
|
calls = await dispatch(event(900001, text="🎲"))
|
||||||
|
assert any("Обычный символ" in (getattr(call, "text", "") or "") for call in calls)
|
||||||
|
update = event(900001, text="🎲")
|
||||||
|
update = update.model_copy(update={"message": update.message.model_copy(update={
|
||||||
|
"entities": [MessageEntity(type="custom_emoji", offset=0, length=2, custom_emoji_id=FIRST_ID)]})})
|
||||||
|
await dispatch(update)
|
||||||
|
await dispatch(event(900001, text="Розыгрыш"))
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
mapping = (await session.scalars(select(EmojiMapping))).one()
|
||||||
|
admin = await UserService.get_user_by_telegram_id(session, 900001)
|
||||||
|
assert mapping.admin_id == admin.id
|
||||||
|
assert mapping.emoji_id == FIRST_ID
|
||||||
|
assert (await EmojiMappingService(session).get_all_emoji_by_admin(900001))[0].id == mapping.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_renders_valid_safe_html_and_rejects_fake_id():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
service = EmojiMappingService(session)
|
||||||
|
with pytest.raises(ValueError, match="custom_emoji_id"):
|
||||||
|
await service.register_emoji("🎲", "🎲", 900001)
|
||||||
|
await service.register_emoji("🎲", FIRST_ID, 900001)
|
||||||
|
rendered = await service.replace_emojis_in_text("<b> & 🎲 🎲")
|
||||||
|
assert rendered == f'<b> & <tg-emoji emoji-id="{FIRST_ID}">🎲</tg-emoji> <tg-emoji emoji-id="{FIRST_ID}">🎲</tg-emoji>'
|
||||||
|
assert await service.restore_emojis_in_text(rendered) == "<b> & 🎲 🎲"
|
||||||
|
with pytest.raises(ValueError, match="уже зарегистрирован"):
|
||||||
|
await service.register_emoji("🎲", FIRST_ID, 900002)
|
||||||
|
assert len(await service.get_all_emojis()) == 1
|
||||||
73
tests/test_redraw_chat.py
Normal file
73
tests/test_redraw_chat.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import Winner, WinnerVerification, User
|
||||||
|
from src.core.services import UserService, LotteryService, ParticipationService
|
||||||
|
from src.core.redraw_services import redraw_unclaimed
|
||||||
|
from src.core.chat_services import ChatSettingsService, BanService, ChatPermissionService
|
||||||
|
from src.core.user_management import UserManagementService
|
||||||
|
|
||||||
|
|
||||||
|
async def run(call, *args):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
return await call(session, *args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_parallel_redraw_replaces_only_expired_unclaimed_prizes_once():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
users = [await UserService.get_or_create_user(session, 800 + i) for i in range(4)]
|
||||||
|
lottery = await LotteryService.create_lottery(session, "Draw", "", ["First", "Second"], users[0].id)
|
||||||
|
lottery_id = lottery.id
|
||||||
|
for user in users:
|
||||||
|
await ParticipationService.add_participant(session, lottery_id, user.id)
|
||||||
|
await LotteryService.conduct_draw(session, lottery_id)
|
||||||
|
winners = await LotteryService.get_winners(session, lottery_id)
|
||||||
|
claimed_id = winners[0].id
|
||||||
|
winners[0].is_claimed = True
|
||||||
|
winners[1].is_notified = True
|
||||||
|
winners[1].created_at = datetime.now(timezone.utc) - timedelta(days=2)
|
||||||
|
await session.commit()
|
||||||
|
results = await asyncio.gather(*(run(redraw_unclaimed, lottery_id) for _ in range(5)))
|
||||||
|
assert sum(len(result) for result in results) == 1
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
remaining = await LotteryService.get_winners(session, lottery_id)
|
||||||
|
assert len(remaining) == 2
|
||||||
|
assert (await session.get(Winner, claimed_id)).is_claimed
|
||||||
|
assert {winner.prize for winner in remaining} == {"First", "Second"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_settings_created_once_under_concurrency():
|
||||||
|
settings = await asyncio.gather(*(run(ChatSettingsService.get_or_create_settings) for _ in range(8)))
|
||||||
|
assert {item.id for item in settings} == {1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_both_admin_interfaces_clear_chat_bans():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
admin = await UserService.get_or_create_user(session, 850)
|
||||||
|
user = await UserService.get_or_create_user(session, 851)
|
||||||
|
user.is_registered = True
|
||||||
|
await session.commit()
|
||||||
|
await BanService.ban_user(session, user.id, user.telegram_id, admin.id)
|
||||||
|
assert not (await ChatPermissionService.can_send_message(session, 851))[0]
|
||||||
|
await UserManagementService.unban_user_in_chat(session, user.id)
|
||||||
|
assert (await ChatPermissionService.can_send_message(session, 851))[0]
|
||||||
|
await BanService.ban_user(session, user.id, user.telegram_id, admin.id)
|
||||||
|
assert await BanService.unban_user(session, 851)
|
||||||
|
assert (await ChatPermissionService.can_send_message(session, 851))[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lottery_delete_includes_verification_children():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_or_create_user(session, 870)
|
||||||
|
lottery = await LotteryService.create_lottery(session, "Draw", "", ["Prize"], user.id)
|
||||||
|
await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||||
|
await LotteryService.conduct_draw(session, lottery.id)
|
||||||
|
winner = (await LotteryService.get_winners(session, lottery.id))[0]
|
||||||
|
session.add(WinnerVerification(winner_id=winner.id, verification_token="test-token",
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=1)))
|
||||||
|
await session.commit()
|
||||||
|
assert await LotteryService.delete_lottery(session, lottery.id)
|
||||||
|
assert not list((await session.scalars(select(WinnerVerification))).all())
|
||||||
Reference in New Issue
Block a user