Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1830f77e | ||
|
|
b0e42688b6 | ||
|
|
c7c60b70c8 | ||
|
|
26c65b7caa | ||
|
|
6fbe4c4f43 | ||
|
|
3906398136 |
@@ -25,12 +25,6 @@ MANIFEST
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
.history/
|
|
||||||
CODEX.MD
|
|
||||||
AGENTS.md
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -84,7 +78,6 @@ docs/_build/
|
|||||||
# Test files
|
# Test files
|
||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
|
||||||
.tox/
|
.tox/
|
||||||
.nox/
|
.nox/
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|||||||
148
.drone.yml
148
.drone.yml
@@ -1,73 +1,113 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: test-and-deploy
|
name: default
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event: [push, pull_request]
|
branch:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
clone:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: checks
|
# Шаг 1: Проверка кода
|
||||||
|
- name: code-quality
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
PYTHONPATH: /drone/src
|
||||||
commands:
|
commands:
|
||||||
- apt-get update && apt-get install -y --no-install-recommends git
|
- apt-get update && apt-get install -y git
|
||||||
- pip install --upgrade pip
|
- pip install --upgrade pip
|
||||||
- pip install -r requirements-dev.txt
|
- pip install flake8 black isort mypy
|
||||||
- python -m compileall -q src main.py scripts migrations
|
- echo "🔍 Проверка стиля кода..."
|
||||||
- ruff check src main.py scripts tests migrations
|
- flake8 --max-line-length=120 --ignore=E203,E501,W503 src/ main.py || echo "⚠️ Предупреждения flake8"
|
||||||
- python scripts/check_secrets.py
|
- echo "🎨 Проверка форматирования..."
|
||||||
- pip-audit -r requirements.txt
|
- black --check --line-length=120 src/ main.py || echo "⚠️ Форматирование может быть улучшено"
|
||||||
- python -m pytest -q
|
- echo "📋 Проверка импортов..."
|
||||||
|
- isort --check-only --profile black src/ main.py || echo "⚠️ Импорты могут быть улучшены"
|
||||||
|
|
||||||
- name: postgres-tests
|
# Шаг 2: Установка зависимостей
|
||||||
|
- name: install-dependencies
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
PYTHONPATH: /drone/src
|
||||||
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"
|
|
||||||
TEST_REDIS_URL: "redis://redis:6379/15"
|
|
||||||
commands:
|
commands:
|
||||||
- pip install -r requirements-dev.txt
|
- echo "📦 Установка зависимостей..."
|
||||||
- python scripts/wait_database.py
|
- pip install --upgrade pip
|
||||||
- alembic upgrade head
|
- pip install -r requirements.txt
|
||||||
- python scripts/check_schema.py
|
- echo "✅ Зависимости установлены"
|
||||||
- python -m pytest -q
|
|
||||||
|
|
||||||
- name: package
|
# Шаг 3: Проверка импортов и синтаксиса
|
||||||
image: python:3.12-slim
|
- name: syntax-check
|
||||||
commands:
|
|
||||||
- python scripts/build_release.py
|
|
||||||
|
|
||||||
- name: deploy
|
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
environment:
|
environment:
|
||||||
DEPLOY_HOST: "192.168.40.112"
|
PYTHONPATH: /drone/src
|
||||||
DEPLOY_USER:
|
DATABASE_URL: sqlite+aiosqlite:///./test.db
|
||||||
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:
|
||||||
- apt-get update && apt-get install -y --no-install-recommends openssh-client
|
- pip install --upgrade pip
|
||||||
- python scripts/drone_deploy.py
|
- pip install -r requirements.txt
|
||||||
|
- 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: [master]
|
branch:
|
||||||
event: [push]
|
- main
|
||||||
status: [success]
|
- master
|
||||||
|
event:
|
||||||
services:
|
- push
|
||||||
- name: redis
|
|
||||||
image: redis:7-alpine
|
|
||||||
|
|
||||||
- name: postgres
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: lottery_test
|
|
||||||
POSTGRES_PASSWORD: lottery_test
|
|
||||||
POSTGRES_DB: lottery_test
|
|
||||||
|
|||||||
18
.env.example
18
.env.example
@@ -9,19 +9,31 @@ BOT_TOKEN=your_bot_token_here
|
|||||||
|
|
||||||
# Для PostgreSQL (рекомендуется для продакшена):
|
# Для PostgreSQL (рекомендуется для продакшена):
|
||||||
DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot
|
DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot
|
||||||
|
DB_CONNECT_TIMEOUT=5
|
||||||
|
DB_POOL_SIZE=5
|
||||||
|
DB_MAX_OVERFLOW=10
|
||||||
|
DB_POOL_TIMEOUT=10
|
||||||
|
DB_POOL_RECYCLE=1800
|
||||||
|
|
||||||
|
# === REDIS ===
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
USE_REDIS_STORAGE=true
|
||||||
|
|
||||||
# === АДМИНИСТРАТОРЫ ===
|
# === АДМИНИСТРАТОРЫ ===
|
||||||
# ID администраторов Telegram (через запятую)
|
# ID администраторов Telegram (через запятую)
|
||||||
# Узнать свой 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
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
|
||||||
|
SQL_ECHO=false
|
||||||
|
|
||||||
|
# Обновлять last_activity не чаще указанного интервала на пользователя
|
||||||
|
ACTIVITY_UPDATE_INTERVAL_SECONDS=300
|
||||||
|
|
||||||
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
|
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
|
||||||
# Максимальное количество участников в одном розыгрыше
|
# Максимальное количество участников в одном розыгрыше
|
||||||
# MAX_PARTICIPANTS_PER_LOTTERY=10000
|
# MAX_PARTICIPANTS_PER_LOTTERY=10000
|
||||||
|
|||||||
23
.env.prod
Normal file
23
.env.prod
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Пример конфигурации для продакшн-окружения
|
||||||
|
# Скопируйте этот файл в .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,16 +10,10 @@ 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@postgres:5432/lottery_bot_db
|
DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@db:5432/lottery_bot_db
|
||||||
COMPOSE_PROFILES=local-db
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
|
||||||
FSM_TTL_SECONDS=86400
|
|
||||||
# Short connection lifetime verified against the production network.
|
|
||||||
DB_POOL_RECYCLE=15
|
|
||||||
|
|
||||||
# ID администраторов (через запятую)
|
# ID администраторов (через запятую)
|
||||||
ADMIN_IDS=123456789,987654321
|
ADMIN_IDS=123456789,987654321
|
||||||
CASHIER_IDS=
|
|
||||||
|
|
||||||
# Настройки логирования
|
# Настройки логирования
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1,4 +0,0 @@
|
|||||||
*.sh text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.py text eol=lf
|
|
||||||
Dockerfile text eol=lf
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,12 +5,6 @@
|
|||||||
.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/
|
|
||||||
|
|
||||||
|
|
||||||
# База данных
|
# База данных
|
||||||
|
|||||||
26
Dockerfile
26
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Multi-stage build для оптимизации размера образа
|
# Multi-stage build для оптимизации размера образа
|
||||||
FROM python:3.12-slim-bookworm AS builder
|
FROM python:3.12-slim as builder
|
||||||
|
|
||||||
# Устанавливаем системные зависимости
|
# Устанавливаем системные зависимости
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -18,19 +18,14 @@ 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-bookworm
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# Создаем пользователя для безопасности
|
# Создаем пользователя для безопасности
|
||||||
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 --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y \
|
||||||
&& install -d /usr/share/postgresql-common/pgdg \
|
curl \
|
||||||
&& 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
|
||||||
@@ -41,15 +36,10 @@ ENV PATH="/opt/venv/bin:$PATH"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем файлы приложения
|
# Копируем файлы приложения
|
||||||
COPY --chown=lottery:lottery src/ ./src/
|
COPY --chown=lottery:lottery . .
|
||||||
COPY --chown=lottery:lottery migrations/ ./migrations/
|
|
||||||
COPY --chown=lottery:lottery scripts/ ./scripts/
|
|
||||||
COPY --chown=lottery:lottery docs/ADMIN_GUIDE.md docs/CASHIER_GUIDE.md docs/SYSTEM_ADMIN_GUIDE.md ./docs/
|
|
||||||
COPY --chown=lottery:lottery main.py alembic.ini ./
|
|
||||||
|
|
||||||
# Создаем необходимые директории
|
# Создаем необходимые директории
|
||||||
RUN mkdir -p /app/logs /app/data && \
|
RUN mkdir -p /app/logs /app/data && \
|
||||||
chmod -R u=rwX,go=rX /app && \
|
|
||||||
chown -R lottery:lottery /app
|
chown -R lottery:lottery /app
|
||||||
|
|
||||||
# Переключаемся на непривилегированного пользователя
|
# Переключаемся на непривилегированного пользователя
|
||||||
@@ -62,7 +52,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 -m src.core.health
|
CMD python -c "from src.core.database import async_session_maker; import asyncio; asyncio.run(async_session_maker().__aenter__())" || exit 1
|
||||||
|
|
||||||
# Открываем порт для мониторинга (если потребуется)
|
# Открываем порт для мониторинга (если потребуется)
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -74,4 +64,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://git.smartsoltech.kr/trevor/new_lottery_bot"
|
org.opencontainers.image.source="https://github.com/your-repo/lottery-bot"
|
||||||
11
Makefile
11
Makefile
@@ -1,7 +1,10 @@
|
|||||||
# Makefile для телеграм-бота розыгрышей
|
# Makefile для телеграм-бота розыгрышей
|
||||||
|
|
||||||
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1)
|
# Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
|
||||||
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null)
|
PYTHON ?= python3.12
|
||||||
|
|
||||||
|
# Определяем команду Docker Compose: предпочтительно v2 (`docker compose`), fallback v1 (`docker-compose`)
|
||||||
|
DOCKER_COMPOSE ?= $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||||
|
|
||||||
.PHONY: help install setup setup-postgres init-db run test clean
|
.PHONY: help install setup setup-postgres init-db run test clean
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ help:
|
|||||||
# Установка зависимостей
|
# Установка зависимостей
|
||||||
install:
|
install:
|
||||||
@echo "📦 Установка зависимостей..."
|
@echo "📦 Установка зависимостей..."
|
||||||
python3 -m venv .venv
|
$(PYTHON) -m venv .venv
|
||||||
. .venv/bin/activate && pip install -r requirements.txt
|
. .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
|
||||||
# Настройка PostgreSQL базы данных
|
# Настройка PostgreSQL базы данных
|
||||||
@@ -217,7 +220,7 @@ docker-setup:
|
|||||||
|
|
||||||
# Проверка Docker и Docker Compose
|
# Проверка Docker и Docker Compose
|
||||||
docker-check:
|
docker-check:
|
||||||
@echo "<EFBFBD> Проверка Docker окружения..."
|
@echo "🔍 Проверка Docker окружения..."
|
||||||
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
|
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
|
||||||
@echo "✅ Docker: $$(docker --version)"
|
@echo "✅ Docker: $$(docker --version)"
|
||||||
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
|
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -2,15 +2,6 @@
|
|||||||
|
|
||||||
Профессиональный телеграм-бот для проведения розыгрышей с расширенными возможностями управления.
|
Профессиональный телеграм-бот для проведения розыгрышей с расширенными возможностями управления.
|
||||||
|
|
||||||
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
|
|
||||||
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
|
|
||||||
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
|
|
||||||
[Проверка счетов и сценариев администратора, кассира и клиента](docs/OPERATOR_SCENARIO_AUDIT.md).
|
|
||||||
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
|
|
||||||
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
|
|
||||||
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
|
|
||||||
Инструкции также доступны в боте: `/admin_help`, `/cashier_help`, `/system_help`.
|
|
||||||
|
|
||||||
## 🌟 Ключевые особенности
|
## 🌟 Ключевые особенности
|
||||||
|
|
||||||
- 🎲 **Создание и управление розыгрышами** - Полный жизненный цикл
|
- 🎲 **Создание и управление розыгрышами** - Полный жизненный цикл
|
||||||
@@ -24,12 +15,11 @@
|
|||||||
- 🐳 **Docker поддержка** - Легкая контейнеризация
|
- 🐳 **Docker поддержка** - Легкая контейнеризация
|
||||||
- 🚀 **CI/CD pipeline** - Автоматическое развертывание
|
- 🚀 **CI/CD pipeline** - Автоматическое развертывание
|
||||||
- 📦 **Модульная архитектура** - Простое расширение функциональности
|
- 📦 **Модульная архитектура** - Простое расширение функциональности
|
||||||
- ✨ **Премиум-эмодзи в чате и рассылках** - Сохранение Telegram-разметки; [условия и использование](docs/EMOJI_SYSTEM.md)
|
|
||||||
|
|
||||||
## 🛠 Технологический стек
|
## 🛠 Технологический стек
|
||||||
|
|
||||||
- **Python 3.12+** - Основной язык
|
- **Python 3.12+** - Основной язык
|
||||||
- **aiogram 3.31** - Telegram Bot API
|
- **aiogram 3.16** - Telegram Bot API
|
||||||
- **SQLAlchemy 2.0.36** - ORM для работы с БД
|
- **SQLAlchemy 2.0.36** - ORM для работы с БД
|
||||||
- **Alembic 1.14** - Система миграций
|
- **Alembic 1.14** - Система миграций
|
||||||
- **PostgreSQL / SQLite** - База данных
|
- **PostgreSQL / SQLite** - База данных
|
||||||
|
|||||||
@@ -1,64 +1,85 @@
|
|||||||
|
# Docker Compose для продакшн-развертывания
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Enable COMPOSE_PROFILES=local-db only when PostgreSQL is hosted in this stack.
|
# PostgreSQL Database
|
||||||
postgres:
|
postgres:
|
||||||
profiles: [local-db]
|
image: postgres:15-alpine
|
||||||
image: postgres:16-alpine
|
container_name: lottery_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [lottery_network]
|
profiles:
|
||||||
|
- local-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot}
|
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-lottery_bot}
|
POSTGRES_USER: ${POSTGRES_USER:-lottery}
|
||||||
# PostgreSQL itself rejects an empty password when local-db is enabled.
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
|
||||||
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" -d "$$POSTGRES_DB"']
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 5
|
||||||
|
|
||||||
|
# Redis для очередей рассылки
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: lottery_redis
|
container_name: lottery_redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [lottery_network]
|
|
||||||
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: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 5
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
bot:
|
bot:
|
||||||
image: ${BOT_IMAGE:-new_lottery_bot:local}
|
build:
|
||||||
build: .
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: lottery_bot
|
container_name: lottery_bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [lottery_network]
|
env_file:
|
||||||
init: true
|
- .env
|
||||||
env_file: .env.prod
|
|
||||||
environment:
|
environment:
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
- USE_REDIS_STORAGE=${USE_REDIS_STORAGE:-true}
|
||||||
|
- DB_CONNECT_TIMEOUT=${DB_CONNECT_TIMEOUT:-5}
|
||||||
|
- DB_POOL_SIZE=${DB_POOL_SIZE:-5}
|
||||||
|
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-10}
|
||||||
|
- DB_POOL_TIMEOUT=${DB_POOL_TIMEOUT:-10}
|
||||||
|
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
|
||||||
|
- ACTIVITY_UPDATE_INTERVAL_SECONDS=${ACTIVITY_UPDATE_INTERVAL_SECONDS:-300}
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
- bot_data:/app/data
|
- bot_data:/app/data
|
||||||
|
networks:
|
||||||
|
- lottery_network
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
stop_grace_period: 45s
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [CMD, python, -m, src.core.health]
|
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
||||||
interval: 15s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 10s
|
||||||
retries: 4
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 10s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bot_data:
|
bot_data:
|
||||||
|
driver: local
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
driver: local
|
||||||
redis_data:
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
lottery_network:
|
lottery_network:
|
||||||
|
|||||||
@@ -1,76 +1,157 @@
|
|||||||
# Инструкция администратора
|
````markdown
|
||||||
|
# <20> Полное руководство по админ-панели
|
||||||
|
|
||||||
Администратор ведёт пользователей, участников и розыгрыши, работает с рассылками и модерацией чата. Назначение сотрудников доступно только системному администратору.
|
## 🎯 Обзор
|
||||||
|
|
||||||
## Вход и навигация
|
Админ-панель предоставляет полный контроль над ботом через удобный интерфейс в Telegram. Доступ: команда `/admin` для администраторов.
|
||||||
|
|
||||||
1. Откройте личный диалог с ботом и отправьте `/start`.
|
## 📍 Главное меню
|
||||||
2. Откройте `/admin` или «⚙️ Админ панель».
|
|
||||||
3. Используйте разделы «Розыгрыши», «Победители», «Участники», «Пользователи», «Рассылки», «Статистика» и «Настройки».
|
|
||||||
4. `/cancel` отменяет текущий ввод. После отмены откройте нужный раздел заново. Уже сохранённые операции эта команда не отменяет.
|
|
||||||
|
|
||||||
Эта инструкция доступна по `/admin_help` и кнопке «Инструкция администратора». Инструкция по кассовым операциям — `/cashier_help`.
|
```
|
||||||
|
🎲 Управление розыгрышами 👥 Управление участниками
|
||||||
|
👑 Управление победителями 📊 Статистика и отчеты
|
||||||
|
⚙️ Настройки системы
|
||||||
|
```
|
||||||
|
|
||||||
## Работа с пользователями
|
---
|
||||||
|
|
||||||
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
|
## 🎲 Управление розыгрышами
|
||||||
Карта вводится цифрами; допустимы и три, и четыре цифры, ведущие нули сохраняются. Если карта заранее привязана к этому же клиенту, он может завершить регистрацию с ней. При ошибке номера или телефона можно повторить ввод на текущем шаге.
|
|
||||||
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
|
|
||||||
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
|
|
||||||
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
|
|
||||||
5. Для деактивации ошибочного счёта: `/remove_account 11-22-33-44-55-66-77`. Если счёт уже включён в открытый розыгрыш, отдельно проверьте и исправьте участие в разделе «Участники».
|
|
||||||
|
|
||||||
Карту, телефон и код получения приза обсуждайте в личном диалоге с клиентом. Общий чат распространяет сообщения другим участникам.
|
### ➕ Создание розыгрыша
|
||||||
|
**Мастер создания в 4 шага:**
|
||||||
|
|
||||||
## Подготовка розыгрыша
|
1. **Название** - введите краткое название
|
||||||
|
2. **Описание** - подробное описание розыгрыша
|
||||||
|
3. **Призы** - список призов (каждый с новой строки)
|
||||||
|
4. **Подтверждение** - проверка и создание
|
||||||
|
|
||||||
1. «Розыгрыши» → «Создать».
|
**Пример:**
|
||||||
2. Введите название, описание (или `-` для пропуска) и призы, каждый с новой строки. Порядок призов соответствует местам.
|
```
|
||||||
3. Проверьте итоговый экран и нажмите «Создать».
|
Название: iPhone 15 Pro Max + призы
|
||||||
4. В «Список всех» откройте карточку, запишите ID розыгрыша и передайте его кассирам вместе с правилами участия.
|
Описание: Крутой розыгрыш с айфоном и дополнительными призами
|
||||||
5. До начала проведения исправьте название, описание или призы через «Редактировать».
|
Призы:
|
||||||
|
iPhone 15 Pro Max 512GB
|
||||||
|
AirPods Pro 2
|
||||||
|
Беспроводная зарядка
|
||||||
|
Чехол Apple
|
||||||
|
```
|
||||||
|
|
||||||
Созданный розыгрыш принимает участников. Завершённый розыгрыш не принимает новые участия.
|
### 📋 Просмотр розыгрышей
|
||||||
|
- **Все розыгрыши** с краткой информацией
|
||||||
|
- **Детальная информация** при выборе
|
||||||
|
- **Статус**: 🟢 Активный / 🔵 Проведен / 🟡 Ожидает
|
||||||
|
- **Количество участников** и победителей
|
||||||
|
|
||||||
## Добавление и проверка участников
|
### ✏️ Редактирование
|
||||||
|
- **Изменение названия** и описания
|
||||||
|
- **Добавление/удаление призов**
|
||||||
|
- **Изменение статуса** розыгрыша
|
||||||
|
|
||||||
- «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке.
|
### 🗑️ Удаление
|
||||||
- «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`.
|
- **Безопасное удаление** со всеми связанными данными
|
||||||
- Кассир добавляет счета через `/cashier` только в формате `КАРТА СЧЁТ`; карта обязательна в каждой строке. Все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода.
|
- **Подтверждение** перед удалением
|
||||||
- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
|
- **Автоматическая очистка** участников и победителей
|
||||||
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
|
|
||||||
|
|
||||||
Для быстрого добавления отправьте в личный диалог `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`, затем «Добавить в розыгрыш» и нужный розыгрыш. Если карта известна боту, новый счёт будет привязан к клиенту одновременно с добавлением участия. Ведущие нули карты сохраняются. Если счёт уже принадлежит другому клиенту или деактивирован, бот откажет в добавлении. Повторное добавление покажет пропуск без создания второго билета.
|
---
|
||||||
|
|
||||||
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
|
## 👥 Управление участниками
|
||||||
|
|
||||||
Для восстановления связи ранее добавленного счёта в открытом розыгрыше отправьте его повторно с правильной картой. Бот привяжет существующее участие без создания второго билета. Перепривязка участия другого владельца отклоняется. Через кассу добавление без карты запрещено.
|
### ➕ Добавление участников
|
||||||
|
|
||||||
## Проведение и выдача призов
|
**Одиночное добавление:**
|
||||||
|
```
|
||||||
|
Пользователь: @username или ID
|
||||||
|
Выберите розыгрыш: [список доступных]
|
||||||
|
```
|
||||||
|
|
||||||
1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.
|
**Массовое добавление:**
|
||||||
2. «Розыгрыши» → «Провести розыгрыш» → нужный розыгрыш. Выполняйте подтверждения, показанные ботом.
|
```
|
||||||
3. Проверьте сохранённый результат в «Победители» → «Список победителей». Проведение закрывает розыгрыш для новых участников.
|
Формат: ID1,ID2,ID3 или @user1,@user2,@user3
|
||||||
4. Передайте кассиру ID розыгрыша. Статус выдачи: `/winner_status ID`, подтверждение по личному коду клиента: `/verify_winner КОД ID`.
|
Выберите розыгрыш: [список]
|
||||||
5. При сетевой ошибке сначала проверьте сохранённый результат или статус выдачи. Повторный запуск не должен использоваться для «исправления» недоставленного уведомления.
|
Автоматическое добавление всех валидных пользователей
|
||||||
|
```
|
||||||
|
|
||||||
«Завершить» закрывает розыгрыш; «Удалить» удаляет его данные. Не используйте удаление для архивирования результатов. Ручные изменения и повторные розыгрыши применяйте только по правилам вашего мероприятия.
|
### 👁️ Просмотр участников
|
||||||
|
- **По розыгрышам** - участники конкретного розыгрыша
|
||||||
|
- **Общий список** - все зарегистрированные пользователи
|
||||||
|
- **Детальная информация**: ID, username, дата регистрации
|
||||||
|
- **Количество участий** каждого пользователя
|
||||||
|
|
||||||
## Рассылки и чат
|
### 🗑️ Удаление участников
|
||||||
|
- **Из конкретного розыгрыша**
|
||||||
|
- **Полное удаление пользователя** из системы
|
||||||
|
- **Подтверждение** перед удалением
|
||||||
|
|
||||||
1. «Рассылки» → выберите отправку в личные сообщения либо настроенный канал/группу.
|
---
|
||||||
2. Отправьте готовое сообщение. После отправки текста/медиа рассылка запускается сразу.
|
|
||||||
3. Во время рассылки можно открыть `/admin` и продолжить работу. Одновременно выполняются до двух рассылок, по одной от администратора.
|
|
||||||
4. Дождитесь отчёта с количеством доставленных и недоставленных сообщений. Недоступный получатель не останавливает остальных. Долгое ожидание Telegram или ошибка сообщения учитываются как неудачная доставка.
|
|
||||||
5. Если рассылка прервана, часть получателей уже могла получить сообщение. Проверьте журнал и результат перед повтором. После перезапуска бота незавершённые фоновые рассылки автоматически не возобновляются.
|
|
||||||
|
|
||||||
Премиум-эмодзи и форматирование сохраняются при копировании исходного сообщения в пределах возможностей Telegram. Ошибка отдельного эмодзи не помечает пользователя как заблокировавшего бот.
|
## 👑 Управление победителями (Ключевая функция)
|
||||||
|
|
||||||
`/chat` включает общий чат, `/exit` выходит из него. Для модерации используйте административные разделы сообщений и чата. Не отправляйте служебные коды и данные клиентов в общий чат.
|
### 🎯 Установка ручных победителей
|
||||||
|
|
||||||
## Если действие не сработало
|
**Процесс:**
|
||||||
|
1. **Выберите розыгрыш** из списка
|
||||||
|
2. **Укажите место** (1, 2, 3...)
|
||||||
|
3. **Выберите пользователя** из участников
|
||||||
|
4. **Подтверждение** установки
|
||||||
|
|
||||||
- «Недостаточно прав»: обратитесь к системному администратору; кассиру административная панель недоступна.
|
**Важно:**
|
||||||
- Бот ждёт другой ввод: `/cancel`, затем нужная команда.
|
- Можно назначить победителей на **любые места**
|
||||||
- Нет активного розыгрыша: проверьте статус и ID в «Список всех».
|
- **Места без назначения** разыгрываются случайно
|
||||||
- Нет ответа: проверьте `/start`; сообщите системному администратору время, действие и ID розыгрыша. Не повторяйте выдачу приза, пока не проверен `/winner_status`.
|
- **Скрытая установка** - участники не знают о ручном назначении
|
||||||
|
|
||||||
|
### 🎲 Проведение розыгрыша
|
||||||
|
|
||||||
|
**Автоматический алгоритм:**
|
||||||
|
1. **Ручные победители** автоматически занимают свои места
|
||||||
|
2. **Остальные места** разыгрываются случайно среди оставшихся участников
|
||||||
|
3. **Результат** выглядит полностью случайным для всех участников
|
||||||
|
|
||||||
|
**Пример результата:**
|
||||||
|
```
|
||||||
|
🏆 Результаты розыгрыша "iPhone + призы"
|
||||||
|
|
||||||
|
🥇 1 место: @winner (iPhone 15 Pro) 👑
|
||||||
|
🥈 2 место: @random_user (AirPods) 🎲
|
||||||
|
🥉 3 место: @preset_user (Зарядка) 👑
|
||||||
|
🏅 4 место: @another_random (Чехол) 🎲
|
||||||
|
```
|
||||||
|
👑 = Ручной победитель | 🎲 = Случайный
|
||||||
|
|
||||||
|
### 📊 Просмотр победителей
|
||||||
|
- **По розыгрышам** - все победители конкретного розыгрыша
|
||||||
|
- **История побед** - все победы пользователя
|
||||||
|
- **Типы побед**: Ручные (👑) и Случайные (🎲)
|
||||||
|
- **Статистика** по каждому пользователю
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Статистика и отчеты
|
||||||
|
|
||||||
|
### <20> Общая статистика
|
||||||
|
```
|
||||||
|
👥 Общее количество пользователей: 1,234
|
||||||
|
🎲 Общее количество розыгрышей: 45
|
||||||
|
👑 Общее количество победителей: 180
|
||||||
|
💎 Общее количество призов: 180
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🏆 Топ рейтинги
|
||||||
|
- **Топ-10 пользователей** по количеству участий
|
||||||
|
- **Топ-10 победителей** по количеству побед
|
||||||
|
- **Самые популярные розыгрыши** по участию
|
||||||
|
- **Недавняя активность** (последние 10 действий)
|
||||||
|
|
||||||
|
### 📁 Экспорт данных
|
||||||
|
- **JSON отчеты** со всей статистикой
|
||||||
|
- **Детальная информация** по всем сущностям
|
||||||
|
- **Готовые файлы** для анализа и архивирования
|
||||||
|
|
||||||
|
### 📊 Производительность
|
||||||
|
- **Время ответа** системы
|
||||||
|
- **Использование памяти** бота
|
||||||
|
- **Статистика использования** админ-панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
(файл сокращён для краткости в docs)
|
||||||
|
````
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
# Актуальная инструкция
|
|
||||||
|
|
||||||
Назначение администраторов и кассиров выполняется через `/staff` с подтверждением по точному Telegram ID.
|
|
||||||
См. [инструкцию системного администратора](SYSTEM_ADMIN_GUIDE.md). Описание старого меню ниже сохранено для истории.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Система управления администраторами
|
# Система управления администраторами
|
||||||
|
|
||||||
## Описание
|
## Описание
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Инструкция кассира
|
|
||||||
|
|
||||||
Кассир привязывает счета к клиентам, добавляет участие в открытых розыгрышах, проверяет победителей и отмечает выдачу призов. Создание и проведение розыгрышей, изменение победителей, управление сотрудниками, рассылки и модерация доступны другим ролям.
|
|
||||||
|
|
||||||
## Начало смены
|
|
||||||
|
|
||||||
1. В личном диалоге с ботом выполните `/start`, затем `/cashier`.
|
|
||||||
2. Если доступа нет, отправьте `/my_id` и передайте показанный Telegram ID системному администратору для назначения роли кассира.
|
|
||||||
3. Получите от администратора ID и название нужного розыгрыша, условия участия и порядок выдачи призов.
|
|
||||||
4. Инструкция доступна по `/cashier_help` и кнопке «Инструкция кассира» в кассе.
|
|
||||||
|
|
||||||
Работайте со своего Telegram-аккаунта. Другой кассир может одновременно выполнять свои операции. `/cancel` отменяет текущий ввод, но не откатывает уже сохранённые счета или выдачу.
|
|
||||||
|
|
||||||
## Привязка счёта к клиенту
|
|
||||||
|
|
||||||
1. Клиент должен запустить бот и пройти регистрацию. Сверьте клубную карту с клиентом по рабочей процедуре.
|
|
||||||
2. Проверьте профиль клиента командой `/user_info 2223`, где `2223` — его клубная карта. Затем отправьте команду, указав карту и счёт:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/add_account 2223 11-22-33-44-55-66-77
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Проверьте ответ бота: карту, владельца и номер счёта.
|
|
||||||
4. Если нужно участие, выберите предложенный розыгрыш. Привязка счёта к клиенту и добавление в розыгрыш — отдельные операции.
|
|
||||||
|
|
||||||
Для нескольких клиентов отправьте `/add_account` без аргументов и следуйте подсказке: по одной паре `КАРТА СЧЁТ` на строку. Проверяйте итоговый отчёт, особенно пропуски и ошибки.
|
|
||||||
|
|
||||||
## Добавление счетов в розыгрыш
|
|
||||||
|
|
||||||
1. `/cashier` → выберите нужное название открытого розыгрыша.
|
|
||||||
2. Отправьте клубную карту и счёт клиента, по одной паре на строку. Карта обязательна для каждого счёта, включая уже известные боту:
|
|
||||||
|
|
||||||
```text
|
|
||||||
0007 11-22-33-44-55-66-77
|
|
||||||
0008 88-99-00-11-22-33-44
|
|
||||||
```
|
|
||||||
|
|
||||||
3. За один ввод допускается до 1000 распознанных счетов.
|
|
||||||
4. Проверьте количества «Добавлено», «Привязано существующих участий», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
|
|
||||||
|
|
||||||
Карта должна быть известна боту. Создание нового счёта и добавление участия сохраняются вместе. Ведущие нули карты сохраняются. Если хотя бы одна строка не содержит карты, весь список возвращается на исправление без добавления его строк; отправьте исправленный список в том же диалоге.
|
|
||||||
|
|
||||||
Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
|
|
||||||
|
|
||||||
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
|
|
||||||
|
|
||||||
Если счёт раньше попал в открытый розыгрыш без клиента, повторите ввод с правильной картой. Бот восстановит связь существующего участия с клиентом, сохранив номер билета и время добавления. Это позволит клиенту видеть счёт, получать уведомление о выигрыше и подтверждать выдачу своим кодом. Владелец уже привязанного участия не заменяется.
|
|
||||||
|
|
||||||
## Исправление счёта
|
|
||||||
|
|
||||||
Для деактивации:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/remove_account 11-22-33-44-55-66-77
|
|
||||||
```
|
|
||||||
|
|
||||||
Деактивация не заменяет удаление уже созданного участия. Если счёт попал не в тот розыгрыш, сообщите администратору ID розыгрыша и номер счёта: он проверит и исправит участие. Не добавляйте произвольный новый счёт вместо ошибочного.
|
|
||||||
|
|
||||||
## Проверка и выдача приза
|
|
||||||
|
|
||||||
1. Узнайте ID проведённого розыгрыша у администратора.
|
|
||||||
2. Проверьте выдачу: `/winner_status 12`, где `12` — пример ID.
|
|
||||||
3. Попросите клиента открыть «Мой код» в своём боте. Сверьте личность/карту и приз по рабочей процедуре. Код не публикуется в общем чате.
|
|
||||||
4. Когда готовы зафиксировать выдачу, отправьте:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/verify_winner AB12CD34 12
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Проверьте подтверждение бота, место, приз и данные победителя. Повторно проверьте `/winner_status 12` при сомнении.
|
|
||||||
|
|
||||||
Команда `/verify_winner` изменяет статус: выигрыш помечается полученным. Это не просто поиск. Повторное подтверждение одного выигрыша отклоняется, в том числе при одновременной работе кассиров. Ошибка уведомления победителю не отменяет сохранённое подтверждение выдачи.
|
|
||||||
|
|
||||||
Если выигрыш не найден, проверьте код, ID, завершение розыгрыша и предыдущую выдачу. Если у клиента несколько призовых мест, согласуйте с администратором порядок выдачи: команда подтверждает одно ещё не полученное место за вызов. При отсутствии привязки победившего счёта к профилю обратитесь к администратору.
|
|
||||||
|
|
||||||
## Когда обращаться к администратору
|
|
||||||
|
|
||||||
- Неверная карта или владелец, неизвестный счёт, ошибка участия.
|
|
||||||
- Нет открытого розыгрыша или требуется изменение результата.
|
|
||||||
- Код не подходит, статус выдачи расходится с фактической выдачей.
|
|
||||||
- Бот не отвечает: попробуйте `/start`, затем сообщите время, команду и ID розыгрыша. Не повторяйте выдачу без проверки статуса.
|
|
||||||
|
|
||||||
Кассиру доступны обычные пользовательские функции, включая чат. `/chat` распространяет сообщения участникам; `/exit` завершает режим чата. Все кассовые команды выполняйте в личном диалоге с ботом.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Автодеплой через 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, затем ещё 120 секунд проверяет heartbeat и отсутствие перезапусков. Только после этого переключает `current` и записывает `last-build`.
|
|
||||||
- При ошибке после остановки пытается вернуть прежний образ. **Это откат приложения:** схема БД автоматически не откатывается; используется env текущего deployment. Восстановление БД из backup требует отдельного решения. При первом запуске предыдущего образа может не быть.
|
|
||||||
|
|
||||||
Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Отдельный watchdog-поток завершает процесс при устаревшем heartbeat, даже если завис драйвер или event loop; после этого действует Docker restart policy. На рабочем сетевом маршруте длительное повторное использование соединений зависало: в `lottery_env` установлен `DB_POOL_RECYCLE=15`, сохраняющий ограниченный пул с коротким сроком жизни соединения. Один только начальный 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,52 +1,244 @@
|
|||||||
# Премиум-эмодзи в чате и рассылках
|
# Система управления кастомными эмодзи
|
||||||
|
|
||||||
Поддержка добавлена для общего чата, личных диалогов, рассылок в личные сообщения, каналы и группы. Сохраняются ID выбранных премиум-эмодзи, оформление текста и подписи к медиа. Новые миграции и переменные окружения для этой функции не требуются.
|
## Обзор
|
||||||
|
|
||||||
## Использование
|
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
|
||||||
|
|
||||||
1. Выберите премиум-эмодзи в панели Telegram и отправьте сообщение в режиме чата либо создания рассылки.
|
1. Бот получает `emoji_id` от Telegram API
|
||||||
2. Для подписи к фото, видео или документу добавьте эмодзи прямо в подпись.
|
2. Сохраняет эмодзи в таблице `emoji_mappings`
|
||||||
3. Бот сохраняет выбранный вариант эмодзи, включая случаи, когда у нескольких премиум-вариантов одинаковый обычный символ.
|
3. При отправке сообщений в чаты бот автоматически использует `emoji_id` вместо текста эмодзи
|
||||||
|
|
||||||
Имя отправителя добавляется с сохранением оформления сообщения. Если заголовок не помещается вместе с текстом или подписью в лимит Telegram, бот отправляет заголовок отдельно и копирует исходное сообщение целиком.
|
Это обеспечивает, что эмодзи будут выглядеть точно так же, как их отправил админ, даже если это премиум эмодзи.
|
||||||
|
|
||||||
Условия Telegram: новые сообщения с custom emoji могут отправлять боты с дополнительным именем пользователя, приобретённым через Fragment. Также они разрешены в сообщениях бота в личных чатах, группах и супергруппах, если **владелец бота** имеет активную подписку Telegram Premium. Исключение по подписке владельца в документации не распространяется на каналы. Эти условия задаёт Telegram; флаг внутри приложения их не меняет. [Официальная документация](https://core.telegram.org/bots/api#formatting-options).
|
## Команды администратора
|
||||||
|
|
||||||
Отправка обычного Unicode-символа передаёт обычный эмодзи. Премиум-вариант определяется полем `custom_emoji_id`, которое Telegram присылает вместе с выбранным эмодзи. Предварительная регистрация в каталоге для чата и рассылки не требуется.
|
### 1. Добавить новый эмодзи
|
||||||
|
|
||||||
## Каталог администратора
|
```
|
||||||
|
/add_emoji
|
||||||
- `/add_emoji` — сохранить один премиум-эмодзи и описание для шаблонов.
|
|
||||||
- `/my_emojis` — показать свои записи.
|
|
||||||
- `/all_emojis` — показать общий каталог.
|
|
||||||
- `/delete_emoji` — удалить свою запись. Главный администратор также может удалять записи других сотрудников.
|
|
||||||
|
|
||||||
Команда добавления принимает настоящий `custom_emoji` из Telegram. Описание ограничено 255 символами. Связь записи с администратором хранится через внутренний ID пользователя в БД; интерфейс принимает Telegram ID. Повторная регистрация даёт понятный ответ без повреждения сессии БД.
|
|
||||||
|
|
||||||
## Реализация
|
|
||||||
|
|
||||||
`src/utils/telegram_messages.py` содержит общий путь передачи сообщений:
|
|
||||||
|
|
||||||
- без заголовка используется `copyMessage`, сохраняющий исходное сообщение;
|
|
||||||
- при добавлении имени передаются исходные `entities` / `caption_entities` с поправкой позиции на длину заголовка в UTF-16;
|
|
||||||
- `parse_mode=None` предотвращает повторный разбор пользовательского текста;
|
|
||||||
- длинные сообщения копируются без обрезания текста или разрыва эмодзи.
|
|
||||||
|
|
||||||
Общий чат, P2P и оба вида рассылок используют этот путь. Ошибка оформления сообщения, включая недопустимый custom emoji, учитывается как ошибка доставки и не блокирует получателя для следующих рассылок.
|
|
||||||
|
|
||||||
Для программно создаваемого шаблона из обычного текста доступен HTML-рендер каталога:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.core.emoji_message_helper import get_emoji_aware_text
|
|
||||||
|
|
||||||
html = await get_emoji_aware_text(session, "🎲 Розыгрыш начался!")
|
|
||||||
await bot.send_message(chat_id, html, parse_mode="HTML")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Вход этого помощника — обычный текст. Он экранирует HTML и добавляет настоящие `<tg-emoji emoji-id="...">...</tg-emoji>`. Входящие пользовательские сообщения сохраняют собственные entities и не проходят через каталог замен.
|
Процесс:
|
||||||
|
1. Админ запускает команду `/add_emoji`
|
||||||
|
2. Бот просит отправить эмодзи
|
||||||
|
3. Админ отправляет эмодзи (например, 🎲)
|
||||||
|
4. Бот просит описание (для чего используется)
|
||||||
|
5. Админ отправляет描述 (например, "Для лотереи")
|
||||||
|
6. Бот сохраняет в БД и подтверждает
|
||||||
|
|
||||||
## Проверка
|
### 2. Просмотр своих эмодзи
|
||||||
|
|
||||||
`tests/test_premium_emoji.py` проверяет ID и UTF-16-позиции нескольких эмодзи, вложенное оформление, подписи к шести типам медиа, предельную длину текста/подписей, P2P-доставку, копирование рассылок, ошибки Telegram и настоящий маршрут регистрации эмодзи. Ответы Telegram в тестах подменены; права конкретного рабочего бота и отображение в клиенте Telegram нужно проверить после установки релиза.
|
```
|
||||||
|
/my_emojis
|
||||||
|
```
|
||||||
|
|
||||||
Итоговый прогон 13 сентября 2026: PostgreSQL — 61 тест прошёл; SQLite — 60 прошли, один тест миграций пропущен. Ruff, compileall и проверка отслеживаемых секретов прошли. Изменения подготовлены локально; установки этой версии на рабочий сервер в рамках проверки не было.
|
Показывает все эмодзи, добавленные этим админом:
|
||||||
|
- Сам эмодзи
|
||||||
|
- Описание
|
||||||
|
- ID (первые 30 символов)
|
||||||
|
- Дату добавления
|
||||||
|
|
||||||
|
### 3. Просмотр всех эмодзи в системе
|
||||||
|
|
||||||
|
```
|
||||||
|
/all_emojis
|
||||||
|
```
|
||||||
|
|
||||||
|
Показывает все эмодзи всех админов с информацией об администраторе
|
||||||
|
|
||||||
|
### 4. Удалить эмодзи
|
||||||
|
|
||||||
|
```
|
||||||
|
/delete_emoji
|
||||||
|
```
|
||||||
|
|
||||||
|
Админ может удалить только свои эмодзи. Процесс:
|
||||||
|
1. Вызвать команду
|
||||||
|
2. Выбрать эмодзи из список (кнопки)
|
||||||
|
3. Бот удалит из БД
|
||||||
|
|
||||||
|
## Использование в коде
|
||||||
|
|
||||||
|
### Простой способ - прямое использование эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
async def handler(message: Message):
|
||||||
|
await message.answer(
|
||||||
|
text="🎲 Добро пожаловать на лотерею! 🏆",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### С обработкой эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_message_helper import get_emoji_aware_text
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
async def handler(message: Message, session: AsyncSession):
|
||||||
|
# Текст с эмодзи
|
||||||
|
original_text = "🎲 Выиграли! 🏆"
|
||||||
|
|
||||||
|
# Обработаны текст (эмодзи заменены на ID для корректного отображения)
|
||||||
|
processed_text = await get_emoji_aware_text(session, original_text)
|
||||||
|
|
||||||
|
await message.answer(processed_text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Работа с EmojiMessageHelper
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_message_helper import EmojiMessageHelper
|
||||||
|
|
||||||
|
async def handler(message: Message, session: AsyncSession):
|
||||||
|
helper = EmojiMessageHelper(session)
|
||||||
|
|
||||||
|
# Обработка перед отправкой
|
||||||
|
text = "🎲 Лотерея начинается! 💎"
|
||||||
|
processed = await helper.process_text_before_send(text)
|
||||||
|
|
||||||
|
await message.answer(processed, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура БД
|
||||||
|
|
||||||
|
### Таблица `emoji_mappings`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `id` | Integer | Primary Key |
|
||||||
|
| `emoji_text` | String(10) | Сам эмодзи (например, 🎲) |
|
||||||
|
| `emoji_id` | String(255) | telegram_emoji_id от API (уникален) |
|
||||||
|
| `admin_id` | Integer | FK на user (администратор) |
|
||||||
|
| `description` | String(255) | Описание назначения эмодзи |
|
||||||
|
| `created_at` | DateTime | Дата добавления |
|
||||||
|
| `last_used_at` | DateTime | Последнее использование |
|
||||||
|
|
||||||
|
### Уникальные ограничения
|
||||||
|
|
||||||
|
- `emoji_id` — уникален во всей системе
|
||||||
|
- `(emoji_text, admin_id)` — один админ не может добавить один эмодзи дважды
|
||||||
|
|
||||||
|
## API сервиса EmojiMappingService
|
||||||
|
|
||||||
|
### Регистрация эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_mapping_service import EmojiMappingService
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
service = EmojiMappingService(session)
|
||||||
|
|
||||||
|
emoji = await service.register_emoji(
|
||||||
|
emoji_text="🎲",
|
||||||
|
emoji_id="telegram_emoji_id_here",
|
||||||
|
admin_id=12345,
|
||||||
|
description="Для лотереи"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
# По тексту
|
||||||
|
emoji = await service.get_emoji_by_text("🎲")
|
||||||
|
|
||||||
|
# По emoji_id
|
||||||
|
emoji = await service.get_emoji_by_id("telegram_emoji_id")
|
||||||
|
|
||||||
|
# Все эмодзи админа
|
||||||
|
emojis = await service.get_all_emoji_by_admin(admin_id=12345)
|
||||||
|
|
||||||
|
# Все эмодзи
|
||||||
|
all_emojis = await service.get_all_emojis()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замена эмодзи в тексте
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Текст → с заменой эмодзи на ID
|
||||||
|
processed = await service.replace_emojis_in_text(
|
||||||
|
"🎲 Выиграли! 🏆"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обратно - ID → эмодзи
|
||||||
|
original = await service.restore_emojis_in_text(processed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить словарь маппинга
|
||||||
|
|
||||||
|
```python
|
||||||
|
# {emoji_text: emoji_id}
|
||||||
|
mapping = await service.get_emoji_mapping_dict()
|
||||||
|
# {'🎲': 'telegram_emoji_id_1', '🏆': 'telegram_emoji_id_2', ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования в разных рутерах
|
||||||
|
|
||||||
|
### В регистрации
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def registration_complete(message: Message, session: AsyncSession):
|
||||||
|
text = "✅ Регистрация завершена! 🎉"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### В админ-панели
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def lottery_created(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
text = "🎰 Новый розыгрыш создан! 🏆"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
await callback.message.edit_text(text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### В чатовой рассылке
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def broadcast_message(message: Message, session: AsyncSession):
|
||||||
|
text = f"📢 Сообщение от админа: {message.text}\n\n💎 Удачи!"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
|
||||||
|
for user_id in target_users:
|
||||||
|
await bot.send_message(user_id, text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важные моменты
|
||||||
|
|
||||||
|
1. **Parse Mode**: Всегда используйте `parse_mode="HTML"` при работе с эмодзи
|
||||||
|
2. **Кеширование ID**: Система не кеширует, каждый раз обращается к БД. Для оптимизации можно добавить кеширование
|
||||||
|
3. **Лог использования**: `last_used_at` обновляется автоматически при замене в тексте
|
||||||
|
4. **Удаление**: Удаленный эмодзи больше не будет заменяться в новых сообщениях
|
||||||
|
5. **Конфликты**: Если два админа добавляют один эмодзи - они сохранятся отдельно (разные admin_id)
|
||||||
|
|
||||||
|
## Миграция
|
||||||
|
|
||||||
|
Таблица создана миграцией:
|
||||||
|
```
|
||||||
|
migrations/versions/20260307_0100_add_emoji_mappings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Применить миграцию:
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trouble Shooting
|
||||||
|
|
||||||
|
### Эмодзи не отображается корректно
|
||||||
|
- Проверьте что используете `parse_mode="HTML"`
|
||||||
|
- Убедитесь что эмодзи зарегистрирован с помощью `/my_emojis`
|
||||||
|
|
||||||
|
### Ошибка "Can't parse entities"
|
||||||
|
- Это означает что есть конфликт форматирования
|
||||||
|
- Убедитесь что используете HTML теги (`<b>`, `<i>`, и т.д.), а не Markdown (`**`, `__`)
|
||||||
|
|
||||||
|
### Эмодзи не заменяется
|
||||||
|
- Проверьте что был зарегистрирован с помощью `/add_emoji`
|
||||||
|
- Убедитесь что используете функцию `get_emoji_aware_text()` перед отправкой
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Проверка операций со счетами и сценариев бота — 14 сентября 2026
|
|
||||||
|
|
||||||
## Что подтвердилось на сервере
|
|
||||||
|
|
||||||
Проверен контейнер `lottery_bot`, исходная версия `f3f8d0f`, деплой №93. Контейнер работал, healthcheck проходил, автоматических перезапусков не было. При этом отдельные действия завершались исключениями: состояние контейнера само по себе не проверяет функциональность меню.
|
|
||||||
|
|
||||||
В логах за сутки найдены `DetachedInstanceError` при добавлении счетов и `ValueError` при выборе «Участники по розыгрышам». Проверка базы во время диагностики показала отсутствие записей счетов и участий с номерами счетов. Поэтому проверены обе причины пропуска: новый счёт с известной картой и повтор уже добавленного счёта. Точный исходный текст клиента в предоставленных логах отсутствует.
|
|
||||||
|
|
||||||
## Исправления
|
|
||||||
|
|
||||||
- При отклонённой операции `rollback()` инвалидировал загруженные ORM-объекты. Итоговый ответ обращался к `lottery.title` после закрытия сессии. Теперь необходимые значения сохраняются до операции; аналогичные обращения исправлены в одиночных и массовых операциях по пользователям.
|
|
||||||
- Быстрый ввод `КАРТА СЧЁТ` не создавал отсутствующий счёт, хотя предварительный экран находил владельца карты. Теперь известная карта позволяет создать счёт и участие одной транзакцией. Уникальность счёта защищает работу кассиров в разных розыгрышах; владелец существующего счёта не меняется.
|
|
||||||
- Широкие фильтры кнопок перехватывали отчёты, подтверждения переигровки и удаления, редактирование победителя. Фильтры теперь проверяют полный формат и допустимый числовой ID. Нераспознанная кнопка получает понятный ответ.
|
|
||||||
- PostgreSQL-прогон выявил несовместимость `SELECT DISTINCT` по розыгрышам с JSON-полями. Меню редактирования и удаления победителей используют `EXISTS`: розыгрыш выводится один раз без сравнения JSON.
|
|
||||||
- Удалённые розыгрыши и истёкшие данные диалога обрабатываются без `NoneType` и `IndexError`.
|
|
||||||
- Добавление и удаление счёта из детального меню используют общие транзакционные проверки открытого розыгрыша и владельца.
|
|
||||||
- Экраны победителей поддерживают участие без Telegram-профиля. При ручном назначении по счёту сохраняется именно выбранный билет; один счёт нельзя назначить на разные призовые места.
|
|
||||||
- Имена, названия с символами HTML и длинные списки не ломают ответы в проверенных операциях со счетами. Длинные отчёты разделяются, кнопки остаются на последней странице. Завершённый диалог очищается до отправки итогового отчёта.
|
|
||||||
|
|
||||||
## Как воспроизводятся сценарии
|
|
||||||
|
|
||||||
`tests/test_operator_scenarios.py` отправляет сообщения и нажатия через настоящий `main.dp.feed_update`. Используются отдельная база и синтетические Telegram ID. Вместо сети транспорт сохраняет вызовы API и проверяет длину текста, структуру HTML и callback-data. Ошибки, перехваченные middleware, также приводят к падению теста.
|
|
||||||
|
|
||||||
Проверяются: быстрый ввод нового и повторного счёта, команда добавления, массовое добавление, касса, просмотр клиентом своих счетов, проведение розыгрыша и выдача приза, конкурирующие кассиры, создание одного счёта в разных розыгрышах, чужая карта, ручные победители, удалённые объекты, старые и некорректные кнопки, отчёты и длинные списки.
|
|
||||||
|
|
||||||
Полный набор дополнительно проверяет регистрацию, премиум-эмодзи, роли сотрудников, недоступных получателей, таймауты отправки, фоновые рассылки, повторную выдачу, миграции и ограничения базы.
|
|
||||||
|
|
||||||
В Drone тесты выполняются на SQLite и отдельно на PostgreSQL 16 с Redis 7. Для PostgreSQL-прогона `TEST_REDIS_URL` включает настоящий Redis FSM и блокировки диалогов. Тестовая очистка ограничена пространством синтетического бота `fsm:123456:*`.
|
|
||||||
|
|
||||||
Эмуляция не создаёт настоящих розыгрышей и не отправляет сообщения клиентам. Она не заменяет проверку интерфейса приложением Telegram. После CI требуется отдельно проверить установленную версию, healthcheck, heartbeat, Telegram API и новые ошибки контейнера.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Проверка и стабилизация бота призовых розыгрышей
|
|
||||||
|
|
||||||
Дата: 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; для предельного размера заголовок отправляется отдельно |
|
|
||||||
| Средний | Разные интерфейсы бана/разбана расходились; повторная запись о блокировке ломала рассылку и задачу неактивности | Оба механизма блокировки согласованы; служебные записи обновляются атомарно |
|
|
||||||
| Средний | Чат повторно отправлял сообщения недоступным адресатам, включая собственную старую запись бота | Собственный ID исключён; отказ Telegram помечает недоступного получателя для пропуска последующих отправок. Входящее событие пользователя восстанавливает доставку; ошибка содержимого не блокирует получателя |
|
|
||||||
| Высокий | При длительном повторном использовании соединения с рабочей БД зависала проверка здоровья; Docker сам не перезапускает unhealthy-контейнер | Настроен короткий срок жизни соединений в рабочем пуле. Отдельный watchdog завершает процесс при остановке heartbeat, включая зависание event loop или отмены запроса драйвера |
|
|
||||||
| Средний | Ошибки 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 локально и PostgreSQL 16 в Drone**. После добавления проверок watchdog и недоступных получателей локально: **65 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. **История секретов.** Runtime-файл удалён из текущей версии Git. Действующий токен бота отличается от удалённого файла, но пароль рабочей БД совпадает с ранее опубликованным. Пароль БД ещё требует согласованной замены во всех потребителях; история Git не переписывалась.
|
|
||||||
4. **Подтверждение клубной карты.** Текущая бизнес-логика принимает введённый пользователем номер карты без проверки по внешней системе или отдельного подтверждения кассиром. Она не доказывает, что карта принадлежит заявителю. Для устранения этого риска требуется определить источник проверки владельца; автоматического подтверждения такой принадлежности сейчас нет.
|
|
||||||
5. **Нагрузка и внешние сбои.** Тесты используют подменённые ответы Telegram и отдельные тестовые БД. Длительная нагрузка с реальными пользователями и потеря сети/Redis ещё не проверены. Рассылки не имеют долговременной очереди восстановления после рестарта; незавершённую рассылку может потребоваться повторить.
|
|
||||||
6. **Производственные данные.** Проверка перед выпуском не обнаружила дубликатов участников или призовых мест. Новая миграция повторяет эту проверку и останавливается при неоднозначных данных; она не удаляет их автоматически.
|
|
||||||
|
|
||||||
Инструкция по секретам и первому запуску: [DRONE_DEPLOYMENT.md](DRONE_DEPLOYMENT.md). Проверка выявленных дефектов не является гарантией отсутствия всех возможных ошибок.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Инструкция системного администратора
|
|
||||||
|
|
||||||
Системный администратор назначает сотрудников и имеет все рабочие права администратора. Его собственный доступ задаётся ответственным за сервер в `ADMIN_IDS` и не снимается через бот.
|
|
||||||
|
|
||||||
## Разделение прав
|
|
||||||
|
|
||||||
| Действие | Системный администратор | Администратор | Кассир |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Назначать администраторов/кассиров, снимать роли | Да | Нет | Нет |
|
|
||||||
| Просматривать список сотрудников | Да | Нет | Нет |
|
|
||||||
| Управлять пользователями, экспортом и модерацией | Да | Да | Нет |
|
|
||||||
| Создавать, редактировать, проводить и закрывать розыгрыши | Да | Да | Нет |
|
|
||||||
| Управлять результатами и удалять участия | Да | Да | Нет |
|
|
||||||
| Запускать административные рассылки | Да | Да | Нет |
|
|
||||||
| Привязывать/деактивировать счета | Да | Да | Да |
|
|
||||||
| Добавлять участие по счетам в открытый розыгрыш | Да | Да | Да |
|
|
||||||
| Проверять и подтверждать выдачу призов | Да | Да | Да |
|
|
||||||
|
|
||||||
Обычные пользовательские функции, включая разрешённый общий чат, доступны сотрудникам. Общий чат и административная рассылка имеют разные назначения.
|
|
||||||
|
|
||||||
## Назначение сотрудника
|
|
||||||
|
|
||||||
1. Сотрудник выполняет `/start`, затем `/my_id` и передаёт вам свой числовой Telegram ID. Сверьте ID с самим сотрудником.
|
|
||||||
2. Откройте `/staff` или `/admin` → «Настройки» → «Сотрудники и права».
|
|
||||||
3. Выберите «Назначить администратора» или «Назначить кассира».
|
|
||||||
4. Введите точный Telegram ID. Поиск по имени при выдаче прав не используется.
|
|
||||||
5. Проверьте имя, ID, текущую и новую роль. Нажмите «Подтвердить изменение».
|
|
||||||
6. Проверьте «Список сотрудников». Попросите сотрудника открыть `/admin` или `/cashier`.
|
|
||||||
|
|
||||||
Можно начать ту же операцию командами `/add_admin TELEGRAM_ID` или `/add_cashier TELEGRAM_ID`. Они также требуют подтверждения кнопкой.
|
|
||||||
|
|
||||||
## Смена роли и отзыв доступа
|
|
||||||
|
|
||||||
У сотрудника одна назначенная роль. Назначение кассиром бывшего администратора снимает его административные права; назначение администратором заменяет роль кассира.
|
|
||||||
|
|
||||||
Для полного отзыва рабочих прав выберите «Снять права сотрудника», введите ID и подтвердите. Пользователь сохраняет обычный доступ к боту. Команды `/remove_admin ID` и `/remove_cashier ID` снимают только соответствующую текущую роль; при несовпадении операция отклоняется.
|
|
||||||
|
|
||||||
Изменения применяются при следующем действии сотрудника, включая продолжение открытого диалога. Уже запущенная рассылка завершается отдельно. Повторное нажатие подтверждения не выполняет назначение снова. Если другой системный администратор успел изменить права, откройте назначение заново и проверьте актуальную роль.
|
|
||||||
|
|
||||||
Системных администраторов нельзя назначать, понижать или удалять через эти команды. Если кассир закреплён в `CASHIER_IDS`, бот направит к ответственному за сервер. Для новых сотрудников используйте назначение через `/staff`; изменение серверной конфигурации и перезапуск не нужны.
|
|
||||||
|
|
||||||
`/admins` и `/cashiers` открывают общий список сотрудников с указанием фактических ролей. Старые кнопки назначения ведут в новый раздел и не меняют права автоматически.
|
|
||||||
|
|
||||||
## Инструкции сотрудникам
|
|
||||||
|
|
||||||
- Администратор: `/admin_help` или [инструкция администратора](ADMIN_GUIDE.md).
|
|
||||||
- Кассир: `/cashier_help` или [инструкция кассира](CASHIER_GUIDE.md).
|
|
||||||
- Эта инструкция: `/system_help` или кнопка в «Сотрудники и права».
|
|
||||||
|
|
||||||
## Проверка работы
|
|
||||||
|
|
||||||
При проблеме запишите время, команду, роль и ID розыгрыша. Проверьте `/start`. Перед повторной рассылкой или выдачей приза проверьте журнал/статус: недоставленный ответ не означает, что операция не была сохранена.
|
|
||||||
|
|
||||||
Ответственный за сервер проверяет состояние контейнера, журнал, доступность базы данных и результат последнего деплоя по [инструкции Drone](DRONE_DEPLOYMENT.md). Изменения ролей журналируются с инициатором, получателем и новой ролью. Не передавайте токены и пароли сотрудникам для назначения через бот.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Проверка текстового ввода
|
|
||||||
|
|
||||||
Проверены зарегистрированные обработчики личных диалогов: регистрация, админка, касса, роли, каналы, эмодзи, общий и личный чат, команды и резервная обработка ввода. Демонстрационные `test_handlers.py` в рабочий диспетчер не подключены.
|
|
||||||
|
|
||||||
## Клубная карта: три и четыре цифры
|
|
||||||
|
|
||||||
Ограничения «не более трёх цифр» в текущем коде нет. В рабочей базе есть карты из четырёх цифр. Проверка полного диалога через диспетчер подтверждает приём `123`, `1234`, `0007` и сохранение ведущих нулей. Номер остаётся строкой; допустимы 1–50 цифр, как и до исправления.
|
|
||||||
|
|
||||||
Исторический отказ именно на четырёх цифрах без текста ответа воспроизвести не удалось. Найдены и исправлены причины потери/остановки диалога:
|
|
||||||
|
|
||||||
- Занятая карта сбрасывала регистрацию. Теперь остаётся шаг карты, можно исправить номер.
|
|
||||||
- Карта, заранее записанная в профиле самого клиента, тоже считалась чужой занятой картой. Теперь клиент может завершить регистрацию со своей картой; карту другого пользователя присвоить нельзя.
|
|
||||||
- Формат карты проверялся только после телефона. Теперь ошибка показывается сразу, на шаге карты.
|
|
||||||
- Неправильный телефон приводил к потере введённых данных. Теперь сохраняются карта и никнейм.
|
|
||||||
- Никнейм записывался второй транзакцией. Теперь никнейм, карта, телефон и регистрация сохраняются вместе.
|
|
||||||
- Сбой ответа Telegram после успешного сохранения оставлял старое состояние. Завершённая регистрация теперь закрывает диалог до отправки подтверждения.
|
|
||||||
- Если диалог уже истёк/отменён, обычное число могло поглощаться обработчиком счетов без ответа. Теперь бот предлагает `/register` или `/start`.
|
|
||||||
|
|
||||||
## Остальные исправления
|
|
||||||
|
|
||||||
| Область | Исправление |
|
|
||||||
|---|---|
|
|
||||||
| Команды во время ввода | Формы не сохраняют и не пересылают `/команды` как данные; известные команды доходят до своих обработчиков, неизвестные получают ответ. `/skip` оставлен только там, где он предусмотрен. |
|
|
||||||
| Регистр команд | Зарегистрированные команды принимаются без учёта регистра. |
|
|
||||||
| Текстовые формы | Добавлены проверки типа сообщения; неподходящее вложение не вызывает обращение к отсутствующему `message.text`. Состояние сохраняется, бот объясняет дальнейшее действие. |
|
|
||||||
| Перехват текста | Слово «регистрация» не перезапускает активную форму. Быстрое удаление по ответу работает по точному триггеру и не перехватывает никнеймы или поля форм. |
|
|
||||||
| Редактирование розыгрыша | Убран выдуманный callback и попытка редактировать сообщение человека. Розыгрыш без описания открывается корректно. Отмена и новое создание не сохраняют ID прежнего редактирования. |
|
|
||||||
| Название, описание, призы | Проверяются пустые/слишком длинные значения, длина названия и каждого приза соответствует базе. Длинное подтверждение разбивается на сообщения, кнопки остаются на последнем. |
|
|
||||||
| Массовые ID | Перенос строки равнозначен запятой. Некорректные ID не превращаются в молчаливую операцию с нулевым результатом. |
|
|
||||||
| Массовые счета | Обрабатываются все строки, табуляция, пара «карта счёт», счёт и карта на отдельных строках, блоки Viposnova. Сохраняется принадлежность карты к счёту. |
|
|
||||||
| Сохранение кассового диалога | Объект SQLAlchemy исключён из данных FSM: структура пригодна для JSON и Redis. При ошибочном вводе можно повторить попытку. |
|
|
||||||
| Числовые ID | Границы чисел проверяются до SQL/Telegram; длинный номер карты в поиске не трактуется как выходящий за диапазон Telegram ID. |
|
|
||||||
| Каналы и модерация | Проверяется ID; экранируются введённые значения в HTML. Ответ `/ban`/`/unban` на копию сообщения определяется по исходному автору в текущем диалоге. |
|
|
||||||
| Чат | Команды не уходят получателю как текст. Выход словами «start»/«старт» использует действующий обработчик главного меню. |
|
|
||||||
|
|
||||||
## Проверки
|
|
||||||
|
|
||||||
`tests/test_text_inputs.py` проверяет полный цикл регистрации, дубликаты карт, конкурентную регистрацию двух клиентов, ошибку отправки подтверждения, ведущие нули, команды и неверный тип сообщения в текстовых формах, отмену/редактирование розыгрыша, длинные подтверждения, многострочный ввод счетов и ID, JSON-сериализацию состояния и команды модерации.
|
|
||||||
|
|
||||||
Проверки выполняются на изолированных SQLite/PostgreSQL с подставным Telegram API. Рабочие карты, телефоны и роли пользователей для тестирования не изменяются. Отдельно проверяются CI, успешный деплой и здоровье работающего бота.
|
|
||||||
|
|
||||||
## Handler inventory
|
|
||||||
|
|
||||||
| Module | Text-capable handlers |
|
|
||||||
|---|---|
|
|
||||||
| [main.py](../main.py) | `cancel_dialog`, `cmd_start`, `btn_lotteries`, `btn_chat`, `btn_registration`, `cmd_register`, `text_registration`, `btn_my_code`, `btn_my_accounts`, `btn_help`, `btn_admin`, `btn_exit_chat`, `btn_main_menu`, `cmd_admin` |
|
|
||||||
| [src/handlers/account_handlers.py](../src/handlers/account_handlers.py) | `detect_account_input` |
|
|
||||||
| [src/handlers/admin_account_handlers.py](../src/handlers/admin_account_handlers.py) | `cancel_command`, `add_account_command`, `process_accounts_data`, `remove_account_command`, `verify_winner_command`, `winner_status_command`, `user_info_command` |
|
|
||||||
| [src/handlers/admin_chat_handlers.py](../src/handlers/admin_chat_handlers.py) | `cmd_chat_mode`, `cmd_set_forward`, `cmd_global_ban`, `cmd_ban`, `cmd_unban`, `cmd_banlist`, `cmd_delete_message`, `cmd_chat_stats` |
|
|
||||||
| [src/handlers/admin_emoji_handlers.py](../src/handlers/admin_emoji_handlers.py) | `add_emoji_start`, `receive_emoji`, `receive_emoji_description`, `list_my_emojis`, `list_all_emojis`, `delete_emoji_start` |
|
|
||||||
| [src/handlers/admin_panel.py](../src/handlers/admin_panel.py) | `process_lottery_title`, `process_lottery_description`, `process_lottery_prizes`, `process_add_participant`, `process_remove_participant`, `process_search_participants`, `process_bulk_add_participant`, `process_bulk_remove_participant`, `process_add_to_lottery`, `process_remove_from_lottery`, `process_bulk_add_accounts`, `process_bulk_remove_accounts`, `process_winner_place`, `process_winner_user`, `admin_broadcast_send`, `admin_broadcast_add_channel_id`, `admin_broadcast_add_channel_description`, `admin_users_search_process` |
|
|
||||||
| [src/handlers/cashier_handlers.py](../src/handlers/cashier_handlers.py) | `cashier_menu`, `add_accounts` |
|
|
||||||
| [src/handlers/chat_handlers.py](../src/handlers/chat_handlers.py) | `enter_chat_command`, `exit_chat_command`, `check_exit_keywords` |
|
|
||||||
| [src/handlers/help_handlers.py](../src/handlers/help_handlers.py) | `help_command` |
|
|
||||||
| [src/handlers/input_fallback.py](../src/handlers/input_fallback.py) | `unknown_command`, `unmatched_input` |
|
|
||||||
| [src/handlers/message_management.py](../src/handlers/message_management.py) | `delete_replied_message`, `quick_delete_replied_message` |
|
|
||||||
| [src/handlers/operator_help.py](../src/handlers/operator_help.py) | `own_id`, `help_command` |
|
|
||||||
| [src/handlers/p2p_chat.py](../src/handlers/p2p_chat.py) | `show_chat_menu`, `handle_p2p_message` |
|
|
||||||
| [src/handlers/redraw_handlers.py](../src/handlers/redraw_handlers.py) | `check_unclaimed_winners`, `redraw_lottery` |
|
|
||||||
| [src/handlers/registration_handlers.py](../src/handlers/registration_handlers.py) | `process_nickname`, `process_club_card`, `process_phone`, `show_verification_code`, `show_user_accounts` |
|
|
||||||
| [src/handlers/staff_handlers.py](../src/handlers/staff_handlers.py) | `staff_menu`, `staff_command`, `staff_target`, `confirmation_pending`, `staff_list_command` |
|
|
||||||
190
main.py
190
main.py
@@ -8,17 +8,14 @@ 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, StateFilter
|
from aiogram.filters import Command
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
from aiogram.fsm.storage.redis import RedisStorage
|
||||||
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, REDIS_URL, FSM_TTL_SECONDS, LOG_LEVEL
|
from src.core.config import BOT_TOKEN, LOG_LEVEL, REDIS_URL, USE_REDIS_STORAGE
|
||||||
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
|
||||||
@@ -35,42 +32,44 @@ from src.handlers.message_management import message_admin_router
|
|||||||
from src.handlers.p2p_chat import router as p2p_chat_router
|
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||||
from src.handlers.help_handlers import router as help_router
|
from src.handlers.help_handlers import router as help_router
|
||||||
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
||||||
|
from src.utils.task_manager import task_manager
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=LOG_LEVEL,
|
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Создание бота и диспетчера
|
# Создание бота и диспетчера
|
||||||
bot = Bot(token=BOT_TOKEN, session=AiohttpSession(timeout=30, limit=100))
|
bot = Bot(token=BOT_TOKEN)
|
||||||
if REDIS_URL:
|
|
||||||
storage = RedisStorage.from_url(
|
|
||||||
REDIS_URL, key_builder=DefaultKeyBuilder(with_bot_id=True),
|
def create_fsm_storage():
|
||||||
state_ttl=FSM_TTL_SECONDS, data_ttl=FSM_TTL_SECONDS,
|
if not USE_REDIS_STORAGE:
|
||||||
)
|
logger.warning("FSM storage: используется MemoryStorage")
|
||||||
isolation = storage.create_isolation(lock_kwargs={"timeout": 3600})
|
return MemoryStorage()
|
||||||
else:
|
|
||||||
storage = MemoryStorage()
|
try:
|
||||||
isolation = SimpleEventIsolation()
|
logger.info("FSM storage: используется RedisStorage")
|
||||||
dp = Dispatcher(storage=storage, events_isolation=isolation)
|
return RedisStorage.from_url(REDIS_URL)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось создать RedisStorage, используется MemoryStorage: {e}")
|
||||||
|
return MemoryStorage()
|
||||||
|
|
||||||
|
|
||||||
|
storage = create_fsm_storage()
|
||||||
|
dp = Dispatcher(storage=storage)
|
||||||
router = Router()
|
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):
|
||||||
"""Middleware для логирования всех callback запросов"""
|
"""Middleware для логирования всех callback запросов"""
|
||||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
|
logger.debug(f"Callback received: data={event.data!r}, user_id={event.from_user.id}")
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
|
logger.debug(f"Callback handled: data={event.data!r}, result={result}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -86,9 +85,8 @@ async def get_controller():
|
|||||||
# === COMMAND HANDLERS ===
|
# === COMMAND HANDLERS ===
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("start"))
|
@router.message(CaseInsensitiveCommand("start"))
|
||||||
async def cmd_start(message: Message, state: FSMContext):
|
async def cmd_start(message: Message):
|
||||||
"""Обработчик команды /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)
|
||||||
|
|
||||||
@@ -100,7 +98,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.components.ui import MessageFormatterImpl
|
from src.display.message_formatter 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
|
||||||
@@ -120,11 +118,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)
|
||||||
await message.answer(lottery_info, parse_mode="HTML")
|
text += lottery_info + "\n" + "="*30 + "\n\n"
|
||||||
|
|
||||||
# Получаем информацию о регистрации пользователя
|
# Получаем информацию о регистрации пользователя
|
||||||
user = await UserService.get_or_create_user(
|
user_service = UserService(session)
|
||||||
session,
|
user = await user_service.get_or_create_user(
|
||||||
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,
|
||||||
@@ -133,7 +131,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=await is_admin(message.from_user.id),
|
is_admin=message.from_user.id in ADMIN_IDS,
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,9 +151,30 @@ 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
|
"""Обработчик кнопки 'Регистрация'"""
|
||||||
await begin_registration(message, state)
|
from src.handlers.registration_handlers import RegistrationStates
|
||||||
|
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"))
|
||||||
@@ -164,7 +183,7 @@ async def cmd_register(message: Message, state: FSMContext):
|
|||||||
await btn_registration(message, state)
|
await btn_registration(message, state)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(None), F.text.lower().in_(["регистрация", "регистр", "register"]))
|
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
|
||||||
async def text_registration(message: Message, state: FSMContext):
|
async def text_registration(message: Message, state: FSMContext):
|
||||||
"""Обработчик текста для регистрации"""
|
"""Обработчик текста для регистрации"""
|
||||||
await btn_registration(message, state)
|
await btn_registration(message, state)
|
||||||
@@ -177,7 +196,7 @@ async def btn_my_code(message: Message):
|
|||||||
await show_verification_code(message)
|
await show_verification_code(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text.in_({"📱 Мои логины", "💳 Мои логины", "<EFBFBD> Мои логины"}))
|
@router.message(F.text == "📱 Мои логины")
|
||||||
async def btn_my_accounts(message: Message):
|
async def btn_my_accounts(message: Message):
|
||||||
"""Обработчик кнопки 'Мои логины'"""
|
"""Обработчик кнопки 'Мои логины'"""
|
||||||
from src.handlers.registration_handlers import show_user_accounts
|
from src.handlers.registration_handlers import show_user_accounts
|
||||||
@@ -192,9 +211,9 @@ async def btn_help(message: Message):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "⚙️ Админ панель")
|
@router.message(F.text == "⚙️ Админ панель")
|
||||||
async def btn_admin(message: Message, state: FSMContext):
|
async def btn_admin(message: Message):
|
||||||
"""Обработчик кнопки 'Админ панель'"""
|
"""Обработчик кнопки 'Админ панель'"""
|
||||||
await cmd_admin(message, state)
|
await cmd_admin(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "🚪 Выйти из чата")
|
@router.message(F.text == "🚪 Выйти из чата")
|
||||||
@@ -205,13 +224,13 @@ async def btn_exit_chat(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "🏠 Главная")
|
@router.message(F.text == "🏠 Главная")
|
||||||
async def btn_main_menu(message: Message, state: FSMContext):
|
async def btn_main_menu(message: Message):
|
||||||
"""Обработчик кнопки 'Главная'"""
|
"""Обработчик кнопки 'Главная'"""
|
||||||
await cmd_start(message, state)
|
await cmd_start(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("admin"))
|
@router.message(CaseInsensitiveCommand("admin"))
|
||||||
async def cmd_admin(message: Message, state: FSMContext):
|
async def cmd_admin(message: Message):
|
||||||
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
@@ -238,7 +257,6 @@ async def cmd_admin(message: Message, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Отправляем сообщение с кнопкой админ панели
|
# Отправляем сообщение с кнопкой админ панели
|
||||||
await state.clear()
|
|
||||||
from src.components.ui import KeyboardBuilderImpl
|
from src.components.ui import KeyboardBuilderImpl
|
||||||
kb = KeyboardBuilderImpl()
|
kb = KeyboardBuilderImpl()
|
||||||
keyboard = kb.get_admin_keyboard()
|
keyboard = kb.get_admin_keyboard()
|
||||||
@@ -259,12 +277,10 @@ 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, state: FSMContext):
|
async def back_to_main_handler(callback: CallbackQuery):
|
||||||
"""Обработчик возврата в главное меню"""
|
"""Обработчик возврата в главное меню"""
|
||||||
await state.clear()
|
|
||||||
await callback.answer()
|
|
||||||
async with get_controller() as controller:
|
async with get_controller() as controller:
|
||||||
await controller.handle_start(callback.message, actor=callback.from_user)
|
await controller.handle_start(callback.message)
|
||||||
# Функции обрабатываются в:
|
# Функции обрабатываются в:
|
||||||
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
# - admin_panel.py: создание розыгрышей, управление пользователями, счетами, чатом, статистика
|
||||||
# - registration_handlers.py: регистрация пользователей
|
# - registration_handlers.py: регистрация пользователей
|
||||||
@@ -277,42 +293,17 @@ async def back_to_main_handler(callback: CallbackQuery, state: FSMContext):
|
|||||||
# Обработка неизвестных callback и сообщений происходит в соответствующих роутерах
|
# Обработка неизвестных callback и сообщений происходит в соответствующих роутерах
|
||||||
|
|
||||||
|
|
||||||
def configure_dispatcher():
|
async def main():
|
||||||
"""Главная функция запуска бота"""
|
"""Главная функция запуска бота"""
|
||||||
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.operator_help import operator_help_router
|
|
||||||
from src.handlers.staff_handlers import staff_router
|
|
||||||
dp.include_router(operator_help_router)
|
|
||||||
dp.include_router(staff_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) # Управление сообщениями администратором
|
||||||
@@ -330,57 +321,27 @@ def configure_dispatcher():
|
|||||||
|
|
||||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||||
from src.handlers.input_fallback import input_fallback_router
|
|
||||||
dp.include_router(input_fallback_router)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
configure_dispatcher()
|
|
||||||
# Запускаем планировщик задач
|
# Запускаем планировщик задач
|
||||||
bot_scheduler.start()
|
bot_scheduler.start()
|
||||||
logger.info("Планировщик задач запущен")
|
logger.info("Планировщик задач запущен")
|
||||||
|
|
||||||
|
await task_manager.start()
|
||||||
|
logger.info("Менеджер фоновых задач запущен")
|
||||||
|
|
||||||
# Запускаем polling
|
# Запускаем polling
|
||||||
try:
|
try:
|
||||||
from src.core.health import HEARTBEAT, heartbeat, start_watchdog
|
|
||||||
HEARTBEAT.unlink(missing_ok=True)
|
|
||||||
watchdog = start_watchdog()
|
|
||||||
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(), name="database-heartbeat")
|
|
||||||
logger.info("Бот запущен")
|
logger.info("Бот запущен")
|
||||||
polling_task = asyncio.create_task(dp.start_polling(bot))
|
await dp.start_polling(bot)
|
||||||
completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED)
|
except Exception as e:
|
||||||
for task in completed:
|
logger.error(f"Ошибка при запуске бота: {e}")
|
||||||
await task
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Runtime supervision failed; stopping the application")
|
|
||||||
raise
|
|
||||||
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()
|
||||||
from src.utils.broadcast_jobs import shutdown_broadcasts
|
await task_manager.stop()
|
||||||
await shutdown_broadcasts()
|
if hasattr(storage, "close"):
|
||||||
await bot.session.close()
|
|
||||||
await storage.close()
|
await storage.close()
|
||||||
await close_db()
|
await bot.session.close()
|
||||||
if "watchdog" in locals():
|
|
||||||
watchdog.set()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -389,5 +350,4 @@ if __name__ == "__main__":
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Бот остановлен пользователем")
|
logger.info("Бот остановлен пользователем")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Критическая ошибка")
|
logger.error(f"Критическая ошибка: {e}")
|
||||||
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.replace("%", "%%"))
|
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# All objects in this autogenerated revision were already introduced by
|
# Duplicate autogenerated migration. The real changes are applied by
|
||||||
# 71376bb89294, 1f1631301809, and b4c435a7dc5f. Keep the revision marker
|
# 71376bb89294, 1f1631301809 and b4c435a7dc5f.
|
||||||
# for deployed databases without creating the same objects twice.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# The preceding revisions own these objects.
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""merge branches
|
"""merge branches
|
||||||
|
|
||||||
Revision ID: merge_migration
|
Revision ID: merge_migration
|
||||||
Revises: cd31303a681c
|
Revises: cd31303a681c, 41aae82e631b
|
||||||
Create Date: 2026-02-18 04:02:12.000000
|
Create Date: 2026-02-18 04:02:12.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -11,7 +11,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'merge_migration'
|
revision = 'merge_migration'
|
||||||
down_revision = 'cd31303a681c'
|
down_revision = ('cd31303a681c', '41aae82e631b')
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "new-lottery-bot"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "Telegram lottery bot"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"aiogram==3.29.0",
|
||||||
|
"aiohttp==3.14.1",
|
||||||
|
"SQLAlchemy==2.0.51",
|
||||||
|
"alembic==1.18.5",
|
||||||
|
"python-dotenv==1.2.2",
|
||||||
|
"asyncpg==0.31.0",
|
||||||
|
"aiosqlite==0.22.1",
|
||||||
|
"redis==8.0.1",
|
||||||
|
"APScheduler==3.11.3",
|
||||||
|
"openpyxl==3.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.1.1",
|
||||||
|
"pytest-asyncio==1.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py312"
|
|
||||||
line-length = 120
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E9", "F821", "F822", "F823"]
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
-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,15 @@
|
|||||||
# Updated for Python 3.12 compatibility
|
# Runtime target: Python 3.12+
|
||||||
aiogram==3.31.0
|
aiogram==3.29.0
|
||||||
aiohttp==3.14.3
|
aiohttp==3.14.1
|
||||||
sqlalchemy==2.0.36
|
SQLAlchemy==2.0.51
|
||||||
alembic==1.14.0
|
alembic==1.18.5
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
asyncpg==0.30.0
|
asyncpg==0.31.0
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.22.1
|
||||||
redis==5.2.1
|
redis==8.0.1
|
||||||
apscheduler==3.10.4
|
APScheduler==3.11.3
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.5
|
||||||
defusedxml==0.7.1
|
|
||||||
|
# Development / diagnostics
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""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", "docs/ADMIN_GUIDE.md",
|
|
||||||
"docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"]
|
|
||||||
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()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -3,12 +3,10 @@
|
|||||||
Скрипт для очистки и инициализации БД с тестовыми данными
|
Скрипт для очистки и инициализации БД с тестовыми данными
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
from src.core.database import async_session_maker
|
||||||
from database import async_session_maker, Base, engine
|
from src.core.models import User, Lottery, Participation, Winner, Account
|
||||||
from models import User, Lottery, Participation, Winner
|
from src.core.services import LotteryService, UserService, ParticipationService
|
||||||
from services import LotteryService, UserService, ParticipationService
|
from sqlalchemy import delete, select, func
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from sqlalchemy import delete, update, select, func
|
|
||||||
|
|
||||||
async def clear_database():
|
async def clear_database():
|
||||||
"""Очистка всех таблиц"""
|
"""Очистка всех таблиц"""
|
||||||
@@ -18,6 +16,7 @@ async def clear_database():
|
|||||||
# Удаляем все данные в правильном порядке (учитывая внешние ключи)
|
# Удаляем все данные в правильном порядке (учитывая внешние ключи)
|
||||||
await session.execute(delete(Winner))
|
await session.execute(delete(Winner))
|
||||||
await session.execute(delete(Participation))
|
await session.execute(delete(Participation))
|
||||||
|
await session.execute(delete(Account))
|
||||||
await session.execute(delete(Lottery))
|
await session.execute(delete(Lottery))
|
||||||
await session.execute(delete(User))
|
await session.execute(delete(User))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -26,8 +25,10 @@ async def clear_database():
|
|||||||
|
|
||||||
def generate_account_numbers(user_id: int, count: int = 10) -> list:
|
def generate_account_numbers(user_id: int, count: int = 10) -> list:
|
||||||
"""Генерация номеров счетов для пользователя"""
|
"""Генерация номеров счетов для пользователя"""
|
||||||
base = user_id * 1000
|
return [
|
||||||
return [f"{base + i:06d}" for i in range(1, count + 1)]
|
"-".join(f"{part:02d}" for part in [user_id, i, 11, 22, 33, 44, 55])
|
||||||
|
for i in range(1, count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
async def create_test_users():
|
async def create_test_users():
|
||||||
"""Создание тестовых пользователей"""
|
"""Создание тестовых пользователей"""
|
||||||
@@ -62,12 +63,8 @@ async def create_test_users():
|
|||||||
# Генерируем и присваиваем номера счетов
|
# Генерируем и присваиваем номера счетов
|
||||||
account_numbers = generate_account_numbers(i, 10)
|
account_numbers = generate_account_numbers(i, 10)
|
||||||
|
|
||||||
# Обновляем счета
|
for account_number in account_numbers:
|
||||||
await session.execute(
|
await UserService.set_account_number(session, user.telegram_id, account_number)
|
||||||
update(User)
|
|
||||||
.where(User.id == user.id)
|
|
||||||
.values(account_number=",".join(account_numbers))
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
created_users.append((user, account_numbers))
|
created_users.append((user, account_numbers))
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/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
|
|
||||||
echo "Verifying application health and restart count for 120 seconds"
|
|
||||||
for attempt in 1 2 3 4 5 6 7 8; do
|
|
||||||
sleep 15
|
|
||||||
docker exec lottery_bot python -m src.core.health
|
|
||||||
test "$(docker inspect --format '{{.RestartCount}}' lottery_bot)" -eq 0
|
|
||||||
done
|
|
||||||
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)"
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
Примеры использования API сервисов для тестирования
|
Примеры использования API сервисов для тестирования
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from database import async_session_maker, init_db
|
from src.core.database import async_session_maker, init_db
|
||||||
from services import UserService, LotteryService, ParticipationService
|
from src.core.services import UserService, LotteryService, ParticipationService
|
||||||
|
|
||||||
|
|
||||||
async def example_usage():
|
async def example_usage():
|
||||||
@@ -23,7 +23,7 @@ async def example_usage():
|
|||||||
first_name="Администратор",
|
first_name="Администратор",
|
||||||
last_name="Бота"
|
last_name="Бота"
|
||||||
)
|
)
|
||||||
# Staff roles are assigned by a system administrator through /staff.
|
await UserService.set_admin(session, 123456789, True)
|
||||||
print(f"✅ Создан админ: {admin.first_name} (@{admin.username})")
|
print(f"✅ Создан админ: {admin.first_name} (@{admin.username})")
|
||||||
|
|
||||||
# Создаем обычных пользователей
|
# Создаем обычных пользователей
|
||||||
@@ -61,7 +61,7 @@ async def example_usage():
|
|||||||
|
|
||||||
# Добавляем участников
|
# Добавляем участников
|
||||||
for user in users:
|
for user in users:
|
||||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||||
if success:
|
if success:
|
||||||
print(f"✅ {user.first_name} присоединился к розыгрышу")
|
print(f"✅ {user.first_name} присоединился к розыгрышу")
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ async def example_usage():
|
|||||||
|
|
||||||
# Проводим розыгрыш
|
# Проводим розыгрыш
|
||||||
results = await LotteryService.conduct_draw(session, lottery.id)
|
results = await LotteryService.conduct_draw(session, lottery.id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
print(f"\n🏆 Результаты розыгрыша:")
|
print(f"\n🏆 Результаты розыгрыша:")
|
||||||
print(f"🎯 Розыгрыш: {lottery.title}")
|
print(f"🎯 Розыгрыш: {lottery.title}")
|
||||||
@@ -134,7 +135,7 @@ async def test_edge_cases():
|
|||||||
user = await UserService.get_user_by_telegram_id(session, 100000001)
|
user = await UserService.get_user_by_telegram_id(session, 100000001)
|
||||||
lottery = (await LotteryService.get_active_lotteries(session))[0]
|
lottery = (await LotteryService.get_active_lotteries(session))[0]
|
||||||
|
|
||||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||||
print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
|
print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
|
||||||
|
|
||||||
# Попытка установить ручного победителя для несуществующего пользователя
|
# Попытка установить ручного победителя для несуществующего пользователя
|
||||||
|
|||||||
@@ -51,11 +51,60 @@ async def list_admins():
|
|||||||
|
|
||||||
|
|
||||||
async def add_admin(telegram_id: int):
|
async def add_admin(telegram_id: int):
|
||||||
print(f"Use /add_admin {telegram_id} in the bot as a system administrator and confirm the change.")
|
"""Добавить администратора"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"❌ Пользователь с ID {telegram_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
if telegram_id in ADMIN_IDS:
|
||||||
|
print(f"❌ ID {telegram_id} - это главный администратор (.env)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if user.is_admin:
|
||||||
|
print(f"❌ Пользователь {user.first_name or user.username} уже администратор")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Назначаем админа
|
||||||
|
success = await UserService.set_admin(session, telegram_id, is_admin=True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
name = user.first_name or user.username or f"@ID_{telegram_id}"
|
||||||
|
print(f"✅ {name} назначен администратором")
|
||||||
|
else:
|
||||||
|
print(f"❌ Ошибка при назначении администратора")
|
||||||
|
|
||||||
|
|
||||||
async def remove_admin(telegram_id: int):
|
async def remove_admin(telegram_id: int):
|
||||||
print(f"Use /remove_admin {telegram_id} in the bot as a system administrator and confirm the change.")
|
"""Удалить администратора"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
if telegram_id in ADMIN_IDS:
|
||||||
|
print(f"❌ Нельзя удалить главного администратора (.env)")
|
||||||
|
print(f" Для изменения отредактируйте .env")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"❌ Пользователь с ID {telegram_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not user.is_admin:
|
||||||
|
print(f"❌ Пользователь {user.first_name or user.username} не является администратором")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Удаляем админа
|
||||||
|
success = await UserService.set_admin(session, telegram_id, is_admin=False)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
name = user.first_name or user.username or f"@ID_{telegram_id}"
|
||||||
|
print(f"✅ Права администратора удалены у {name}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Ошибка при удалении прав администратора")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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,12 +34,50 @@ class LotteryServiceImpl(ILotteryService):
|
|||||||
created_at=datetime.now(timezone.utc)
|
created_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def conduct_draw(self, lottery_id):
|
async def conduct_draw(self, lottery_id: int) -> Dict[str, Any]:
|
||||||
from src.core.services import LotteryService
|
"""Провести розыгрыш"""
|
||||||
result = await LotteryService.conduct_draw(self.lottery_repo.session, lottery_id)
|
lottery = await self.lottery_repo.get_by_id(lottery_id)
|
||||||
winners = await self.winner_repo.get_by_lottery(lottery_id) if result else []
|
if not lottery or lottery.is_completed:
|
||||||
return {str(place): dict(info, winner=next(w for w in winners if w.place == place))
|
return {}
|
||||||
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]:
|
||||||
"""Получить активные розыгрыши"""
|
"""Получить активные розыгрыши"""
|
||||||
@@ -54,13 +92,26 @@ 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:
|
||||||
"""Получить или создать пользователя"""
|
"""Получить или создать пользователя"""
|
||||||
from src.core.services import UserService
|
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||||
return await UserService.get_or_create_user(self.user_repo.session, telegram_id, **kwargs)
|
if not user:
|
||||||
|
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
|
"""Зарегистрировать пользователя"""
|
||||||
try:
|
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||||
await RegistrationService.register_user(self.user_repo.session, telegram_id, club_card_number, phone)
|
if not user:
|
||||||
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
|
||||||
@@ -72,29 +72,22 @@ class MessageFormatterImpl(IMessageFormatter):
|
|||||||
text = text[:47] + "..."
|
text = text[:47] + "..."
|
||||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")])
|
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
class MessageFormatterImpl(IMessageFormatter):
|
|
||||||
"""Реализация форматирования сообщений"""
|
|
||||||
|
|
||||||
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
||||||
"""Форматировать информацию о розыгрыше"""
|
"""Форматировать информацию о розыгрыше"""
|
||||||
from html import escape
|
text = f"🎲 **{lottery.title}**\n\n"
|
||||||
text = f"🎲 <b>{escape(lottery.title)}</b>\n\n"
|
|
||||||
|
|
||||||
if lottery.description:
|
if lottery.description:
|
||||||
text += f"📝 {escape(lottery.description[:1200])}\n\n"
|
text += f"📝 {lottery.description}\n\n"
|
||||||
|
|
||||||
text += f"👥 Участников: {participants_count}\n"
|
text += f"👥 Участников: {participants_count}\n"
|
||||||
|
|
||||||
if lottery.prizes:
|
if lottery.prizes:
|
||||||
text += "\n🏆 <b>Призы:</b>\n"
|
text += "\n🏆 **Призы:**\n"
|
||||||
for i, prize in enumerate(lottery.prizes[:5], 1):
|
for i, prize in enumerate(lottery.prizes, 1):
|
||||||
text += f"{i}. {escape(str(prize)[:100])}\n"
|
text += f"{i}. {prize}\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}"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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
|
||||||
@@ -30,27 +29,26 @@ class BotController(IBotController):
|
|||||||
self.lottery_repo = lottery_repo
|
self.lottery_repo = lottery_repo
|
||||||
self.participation_repo = participation_repo
|
self.participation_repo = participation_repo
|
||||||
|
|
||||||
async def is_admin(self, user_id: int) -> bool:
|
def is_admin(self, user_id: int) -> bool:
|
||||||
"""Проверить, является ли пользователь администратором"""
|
"""Проверить, является ли пользователь администратором"""
|
||||||
return await is_admin(user_id)
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
async def handle_start(self, message: Message, actor=None):
|
async def handle_start(self, message: Message):
|
||||||
"""Обработать команду /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=actor.id,
|
telegram_id=message.from_user.id,
|
||||||
username=actor.username,
|
username=message.from_user.username,
|
||||||
first_name=actor.first_name,
|
first_name=message.from_user.first_name,
|
||||||
last_name=actor.last_name
|
last_name=message.from_user.last_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Логирование статуса регистрации
|
# Логирование статуса регистрации
|
||||||
staff_admin = await self.is_admin(actor.id)
|
logger.info(f"User {message.from_user.id}: is_registered={user.is_registered}, is_admin={self.is_admin(message.from_user.id)}")
|
||||||
|
|
||||||
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
||||||
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
||||||
@@ -62,19 +60,13 @@ class BotController(IBotController):
|
|||||||
|
|
||||||
# Inline клавиатура
|
# Inline клавиатура
|
||||||
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
is_admin=staff_admin,
|
is_admin=self.is_admin(message.from_user.id),
|
||||||
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=staff_admin,
|
is_admin=self.is_admin(message.from_user.id),
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +94,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)
|
||||||
await callback.message.answer(lottery_info, parse_mode="HTML")
|
text += lottery_info + "\n" + "="*30 + "\n\n"
|
||||||
|
|
||||||
# Получаем информацию о регистрации пользователя
|
# Получаем информацию о регистрации пользователя
|
||||||
user = await self.user_service.get_or_create_user(
|
user = await self.user_service.get_or_create_user(
|
||||||
@@ -113,7 +105,7 @@ class BotController(IBotController):
|
|||||||
)
|
)
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_main_keyboard(
|
keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
is_admin=await self.is_admin(callback.from_user.id),
|
is_admin=self.is_admin(callback.from_user.id),
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy import select, and_, update
|
from sqlalchemy import select, and_, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .models import User, BlockedUser
|
from .models import User, BlockedUser
|
||||||
@@ -41,6 +41,34 @@ class ActivityService:
|
|||||||
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
|
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_activity_and_reactivate(session: AsyncSession, telegram_id: int) -> Optional[bool]:
|
||||||
|
"""Обновить активность и снять блокировку за неактивность одним коммитом."""
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.telegram_id == telegram_id)
|
||||||
|
.values(last_activity=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
update(BlockedUser)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
BlockedUser.telegram_id == telegram_id,
|
||||||
|
BlockedUser.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(is_active=False, last_attempt_at=now)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обновления активности/реактивации пользователя {telegram_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_inactive_users(
|
async def get_inactive_users(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -84,24 +112,45 @@ class ActivityService:
|
|||||||
Количество помеченных пользователей
|
Количество помеченных пользователей
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
days = days if days is not None else ActivityService.INACTIVITY_PERIOD_DAYS
|
if days is None:
|
||||||
if session.bind.dialect.name == "postgresql":
|
days = ActivityService.INACTIVITY_PERIOD_DAYS
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
|
||||||
else:
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
from sqlalchemy.dialects.sqlite import insert
|
already_blocked = (
|
||||||
inactive_users = await ActivityService.get_inactive_users(session, days)
|
select(BlockedUser.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
BlockedUser.telegram_id == User.telegram_id,
|
||||||
|
BlockedUser.error_type == 'inactive',
|
||||||
|
BlockedUser.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(
|
||||||
|
and_(
|
||||||
|
User.last_activity < cutoff_date,
|
||||||
|
User.is_registered == True,
|
||||||
|
~already_blocked,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inactive_users = list(result.scalars().all())
|
||||||
marked_count = 0
|
marked_count = 0
|
||||||
|
|
||||||
for user in inactive_users:
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
values = dict(error_type='inactive', error_message=f'User inactive for {days} days',
|
for user in inactive_users:
|
||||||
last_attempt_at=now, attempt_count=1, is_active=True)
|
session.add(BlockedUser(
|
||||||
statement = insert(BlockedUser).values(telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
first_blocked_at=now, **values)
|
error_type='inactive',
|
||||||
result = await session.execute(statement.on_conflict_do_update(
|
error_message=f'User inactive for {days} days',
|
||||||
index_elements=[BlockedUser.telegram_id], set_=values,
|
first_blocked_at=now,
|
||||||
where=BlockedUser.is_active.is_(False)))
|
last_attempt_at=now,
|
||||||
marked_count += result.rowcount
|
attempt_count=1,
|
||||||
|
is_active=True
|
||||||
|
))
|
||||||
|
marked_count += 1
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return marked_count
|
return marked_count
|
||||||
@@ -133,7 +182,7 @@ class ActivityService:
|
|||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BlockedUser.telegram_id == telegram_id,
|
BlockedUser.telegram_id == telegram_id,
|
||||||
BlockedUser.error_type.in_(['inactive', 'blocked_bot', 'deactivated', 'not_found', 'chat_not_found']),
|
BlockedUser.error_type == 'inactive',
|
||||||
BlockedUser.is_active == True
|
BlockedUser.is_active == True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ 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, MAX_RETRY_AFTER
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
@@ -17,7 +15,7 @@ import redis.asyncio as redis
|
|||||||
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
||||||
from .config import REDIS_URL, ADMIN_IDS
|
from .config import REDIS_URL, ADMIN_IDS
|
||||||
from .database import async_session_maker
|
from .database import async_session_maker
|
||||||
from src.utils.telegram_messages import copy_preserving_entities
|
from .delivery import delivery_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -94,6 +92,8 @@ class BroadcastService:
|
|||||||
# Лимиты Telegram
|
# Лимиты Telegram
|
||||||
BATCH_SIZE = 30 # Сообщений в пакете
|
BATCH_SIZE = 30 # Сообщений в пакете
|
||||||
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
|
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
|
||||||
|
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
|
||||||
|
MAX_RETRY_AFTER = 30
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.redis_queue = RedisQueue()
|
self.redis_queue = RedisQueue()
|
||||||
@@ -132,18 +132,9 @@ class BroadcastService:
|
|||||||
error_type: Тип ошибки
|
error_type: Тип ошибки
|
||||||
error_message: Сообщение об ошибке
|
error_message: Сообщение об ошибке
|
||||||
"""
|
"""
|
||||||
if session.bind.dialect.name == "postgresql":
|
await delivery_service.mark_user_blocked(
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
session, telegram_id, error_type, error_message
|
||||||
else:
|
)
|
||||||
from sqlalchemy.dialects.sqlite import insert
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
statement = insert(BlockedUser).values(telegram_id=telegram_id, error_type=error_type,
|
|
||||||
error_message=error_message, last_attempt_at=now, attempt_count=1, is_active=True)
|
|
||||||
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,
|
|
||||||
"attempt_count": BlockedUser.attempt_count + 1, "is_active": True}))
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
|
||||||
|
|
||||||
async def unblock_user(self, session: AsyncSession, telegram_id: int):
|
async def unblock_user(self, session: AsyncSession, telegram_id: int):
|
||||||
"""
|
"""
|
||||||
@@ -153,25 +144,15 @@ class BroadcastService:
|
|||||||
session: Сессия БД
|
session: Сессия БД
|
||||||
telegram_id: Telegram ID пользователя
|
telegram_id: Telegram ID пользователя
|
||||||
"""
|
"""
|
||||||
stmt = select(BlockedUser).where(
|
await delivery_service.unblock_user(session, telegram_id)
|
||||||
BlockedUser.telegram_id == telegram_id,
|
|
||||||
BlockedUser.is_active == True
|
|
||||||
)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
blocked_user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if blocked_user:
|
|
||||||
blocked_user.is_active = False
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"Пользователь {telegram_id} разблокирован")
|
|
||||||
|
|
||||||
@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
|
retry: bool = True,
|
||||||
|
skip_block_check: bool = False,
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Отправить сообщение пользователю с обработкой ошибок
|
Отправить сообщение пользователю с обработкой ошибок
|
||||||
@@ -184,62 +165,43 @@ class BroadcastService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, Optional[str]]: (успех, тип_ошибки)
|
Tuple[bool, Optional[str]]: (успех, тип_ошибки)
|
||||||
"""
|
"""
|
||||||
try:
|
if message.text:
|
||||||
# Проверяем, не заблокирован ли пользователь
|
send_call = lambda: bot.send_message(
|
||||||
async with async_session_maker() as session:
|
user.telegram_id,
|
||||||
is_blocked = await self.check_user_blocked(session, user.telegram_id)
|
message.text,
|
||||||
if is_blocked:
|
parse_mode="Markdown"
|
||||||
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
|
)
|
||||||
return False, "blocked"
|
elif message.photo:
|
||||||
|
send_call = lambda: bot.send_photo(
|
||||||
# Отправляем сообщение
|
user.telegram_id,
|
||||||
# Copy preserves Telegram entities and captions without reparsing user text.
|
photo=message.photo[-1].file_id,
|
||||||
await copy_preserving_entities(message, user.telegram_id)
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
)
|
||||||
async with async_session_maker() as session:
|
elif message.video:
|
||||||
await self.unblock_user(session, user.telegram_id)
|
send_call = lambda: bot.send_video(
|
||||||
|
user.telegram_id,
|
||||||
return True, None
|
video=message.video.file_id,
|
||||||
|
caption=message.caption,
|
||||||
except TelegramForbiddenError as e:
|
parse_mode="Markdown"
|
||||||
# Пользователь заблокировал бота
|
)
|
||||||
error_type = "blocked_bot"
|
elif message.document:
|
||||||
async with async_session_maker() as session:
|
send_call = lambda: bot.send_document(
|
||||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
user.telegram_id,
|
||||||
return False, error_type
|
document=message.document.file_id,
|
||||||
|
caption=message.caption,
|
||||||
except TelegramBadRequest as e:
|
parse_mode="Markdown"
|
||||||
# Пользователь удален или деактивирован
|
)
|
||||||
error_str = str(e).lower()
|
|
||||||
if "user is deactivated" in error_str:
|
|
||||||
error_type = "deactivated"
|
|
||||||
elif "user not found" in error_str:
|
|
||||||
error_type = "not_found"
|
|
||||||
elif "chat not found" in error_str:
|
|
||||||
error_type = "chat_not_found"
|
|
||||||
else:
|
else:
|
||||||
error_type = "bad_request"
|
send_call = lambda: message.copy_to(user.telegram_id)
|
||||||
|
|
||||||
# A rejected custom emoji or malformed message does not mean the recipient blocked us.
|
result = await delivery_service.send_with_guard(
|
||||||
if error_type != "bad_request":
|
user.telegram_id,
|
||||||
async with async_session_maker() as session:
|
send_call,
|
||||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
retry=retry,
|
||||||
return False, error_type
|
skip_block_check=skip_block_check,
|
||||||
|
)
|
||||||
except TelegramRetryAfter as e:
|
return result.success, None if result.success else result.status
|
||||||
# FloodWait - слишком много запросов
|
|
||||||
logger.warning("Telegram rate limit: retry_after=%s", e.retry_after)
|
|
||||||
if _retry or e.retry_after > MAX_RETRY_AFTER:
|
|
||||||
return False, "rate_limited"
|
|
||||||
await asyncio.sleep(e.retry_after)
|
|
||||||
# Повторная попытка
|
|
||||||
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Другие ошибки
|
|
||||||
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
|
|
||||||
return False, "unknown_error"
|
|
||||||
|
|
||||||
async def broadcast_to_users(
|
async def broadcast_to_users(
|
||||||
self,
|
self,
|
||||||
@@ -275,6 +237,8 @@ class BroadcastService:
|
|||||||
await session.refresh(broadcast_log)
|
await session.refresh(broadcast_log)
|
||||||
log_id = broadcast_log.id
|
log_id = broadcast_log.id
|
||||||
|
|
||||||
|
users_prefiltered = False
|
||||||
|
|
||||||
# Получаем список пользователей
|
# Получаем список пользователей
|
||||||
if users is None:
|
if users is None:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -292,6 +256,7 @@ class BroadcastService:
|
|||||||
|
|
||||||
# Фильтруем пользователей, исключая заблокированных
|
# Фильтруем пользователей, исключая заблокированных
|
||||||
users = [u for u in all_users if u.telegram_id not in blocked_ids]
|
users = [u for u in all_users if u.telegram_id not in blocked_ids]
|
||||||
|
users_prefiltered = True
|
||||||
|
|
||||||
total_users = len(users)
|
total_users = len(users)
|
||||||
success_count = 0
|
success_count = 0
|
||||||
@@ -305,7 +270,11 @@ class BroadcastService:
|
|||||||
# Отправляем пакет
|
# Отправляем пакет
|
||||||
tasks = []
|
tasks = []
|
||||||
for user in batch:
|
for user in batch:
|
||||||
tasks.append(self.send_message_to_user(bot, user, message))
|
tasks.append(
|
||||||
|
self.send_message_to_user(
|
||||||
|
bot, user, message, skip_block_check=users_prefiltered
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Ждем завершения пакета
|
# Ждем завершения пакета
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -384,7 +353,32 @@ 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,13 +24,10 @@ class ChatSettingsService:
|
|||||||
"""Получить или создать настройки чата"""
|
"""Получить или создать настройки чата"""
|
||||||
settings = await ChatSettingsService.get_settings(session)
|
settings = await ChatSettingsService.get_settings(session)
|
||||||
if not settings:
|
if not settings:
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
settings = ChatSettings(id=1, mode='broadcast', global_ban=False)
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
session.add(settings)
|
||||||
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()
|
||||||
settings = await ChatSettingsService.get_settings(session)
|
await session.refresh(settings)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -76,7 +73,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
|
||||||
|
|
||||||
@@ -89,7 +86,6 @@ 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(
|
||||||
@@ -99,7 +95,7 @@ class BanService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = existing_ban.scalars().first()
|
existing = existing_ban.scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Обновляем причину
|
# Обновляем причину
|
||||||
@@ -124,8 +120,6 @@ 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(
|
||||||
@@ -137,7 +131,7 @@ class BanService:
|
|||||||
.values(is_active=False)
|
.values(is_active=False)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount > 0 or user_result.rowcount > 0
|
return 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]:
|
||||||
@@ -195,26 +189,47 @@ 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."""
|
"""
|
||||||
from sqlalchemy import or_
|
Получить сообщение по telegram_message_id
|
||||||
query = select(ChatMessage).options(selectinload(ChatMessage.sender)).where(
|
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
|
||||||
ChatMessage.is_deleted.is_(False))
|
"""
|
||||||
if chat_id is not None:
|
# Сначала ищем по оригинальному telegram_message_id
|
||||||
query = query.where(or_(
|
query = select(ChatMessage).where(
|
||||||
and_(ChatMessage.telegram_message_id == telegram_message_id,
|
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:
|
||||||
elif user_id is not None:
|
query = query.where(ChatMessage.user_id == user_id)
|
||||||
query = query.where(ChatMessage.user_id == user_id,
|
|
||||||
ChatMessage.telegram_message_id == telegram_message_id)
|
result = await session.execute(query)
|
||||||
else:
|
message = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Если нашли - возвращаем
|
||||||
|
if message:
|
||||||
|
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
|
return None
|
||||||
matches = (await session.scalars(query.limit(2))).all()
|
|
||||||
return matches[0] if len(matches) == 1 else None
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_messages(
|
async def get_user_messages(
|
||||||
@@ -353,8 +368,6 @@ 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)
|
||||||
|
|||||||
@@ -11,10 +11,18 @@ if not BOT_TOKEN:
|
|||||||
|
|
||||||
# База данных
|
# База данных
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||||
|
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
|
||||||
|
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
||||||
|
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
|
||||||
|
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
|
||||||
|
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
FSM_TTL_SECONDS = int(os.getenv("FSM_TTL_SECONDS", "86400"))
|
USE_REDIS_STORAGE = os.getenv("USE_REDIS_STORAGE", "true").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
# Активность пользователей
|
||||||
|
ACTIVITY_UPDATE_INTERVAL_SECONDS = int(os.getenv("ACTIVITY_UPDATE_INTERVAL_SECONDS", "300"))
|
||||||
|
|
||||||
# Администраторы
|
# Администраторы
|
||||||
ADMIN_IDS = []
|
ADMIN_IDS = []
|
||||||
@@ -28,9 +36,6 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -9,29 +8,35 @@ load_dotenv()
|
|||||||
|
|
||||||
# Конфигурация базы данных
|
# Конфигурация базы данных
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||||
|
SQL_ECHO = os.getenv("SQL_ECHO", "false").lower() in {"1", "true", "yes", "on"}
|
||||||
|
DB_CONNECT_TIMEOUT = float(os.getenv("DB_CONNECT_TIMEOUT", "5"))
|
||||||
|
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
|
||||||
|
DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
|
||||||
|
DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))
|
||||||
|
DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||||
|
|
||||||
# Создаем асинхронный движок
|
# Создаем асинхронный движок
|
||||||
engine_options = {"echo": False, "pool_pre_ping": True, "hide_parameters": True}
|
engine_kwargs = {
|
||||||
if DATABASE_URL.startswith("sqlite"):
|
"echo": SQL_ECHO,
|
||||||
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"):
|
if DATABASE_URL.startswith("postgresql+asyncpg"):
|
||||||
@event.listens_for(engine.sync_engine, "connect")
|
engine_kwargs.update(
|
||||||
def configure_sqlite(connection, _):
|
{
|
||||||
cursor = connection.cursor()
|
"pool_pre_ping": True,
|
||||||
cursor.execute("PRAGMA foreign_keys=ON")
|
"pool_size": DB_POOL_SIZE,
|
||||||
cursor.execute("PRAGMA busy_timeout=30000")
|
"max_overflow": DB_MAX_OVERFLOW,
|
||||||
cursor.close()
|
"pool_timeout": DB_POOL_TIMEOUT,
|
||||||
|
"pool_recycle": DB_POOL_RECYCLE,
|
||||||
|
"connect_args": {"timeout": DB_CONNECT_TIMEOUT},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
**engine_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
# Создаем фабрику сессий
|
# Создаем фабрику сессий
|
||||||
async_session_maker = async_sessionmaker(
|
async_session_maker = async_sessionmaker(
|
||||||
@@ -50,7 +55,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
185
src/core/delivery.py
Normal file
185
src/core/delivery.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Safe Telegram delivery helpers.
|
||||||
|
|
||||||
|
The bot sends messages from several places: broadcasts, winner notifications,
|
||||||
|
P2P chat, and chat forwarding. This module keeps the "user blocked the bot"
|
||||||
|
handling in one place so every outbound path behaves consistently.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .database import async_session_maker
|
||||||
|
from .models import BlockedUser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DeliveryResult:
|
||||||
|
success: bool
|
||||||
|
status: Optional[str] = None
|
||||||
|
telegram_message: Any = None
|
||||||
|
skipped: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryService:
|
||||||
|
"""Guarded Telegram delivery with persistent blocked-user tracking."""
|
||||||
|
|
||||||
|
RETRY_AFTER_DELAY = 5.0
|
||||||
|
MAX_RETRY_AFTER = 30
|
||||||
|
|
||||||
|
_BAD_REQUEST_ERROR_TYPES = {
|
||||||
|
"user is deactivated": "deactivated",
|
||||||
|
"user not found": "not_found",
|
||||||
|
"chat not found": "chat_not_found",
|
||||||
|
"bot was blocked by the user": "blocked_bot",
|
||||||
|
"forbidden": "blocked_bot",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_user_blocked(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_id: int,
|
||||||
|
) -> Optional[BlockedUser]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(BlockedUser).where(
|
||||||
|
BlockedUser.telegram_id == telegram_id,
|
||||||
|
BlockedUser.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def mark_user_blocked(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_id: int,
|
||||||
|
error_type: str,
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = await session.execute(
|
||||||
|
select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
blocked_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if blocked_user:
|
||||||
|
blocked_user.error_type = error_type
|
||||||
|
blocked_user.error_message = error_message
|
||||||
|
blocked_user.last_attempt_at = now
|
||||||
|
blocked_user.attempt_count = (blocked_user.attempt_count or 0) + 1
|
||||||
|
blocked_user.is_active = True
|
||||||
|
else:
|
||||||
|
session.add(
|
||||||
|
BlockedUser(
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
first_blocked_at=now,
|
||||||
|
last_attempt_at=now,
|
||||||
|
attempt_count=1,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info("User %s marked as unavailable: %s", telegram_id, error_type)
|
||||||
|
|
||||||
|
async def unblock_user(self, session: AsyncSession, telegram_id: int) -> bool:
|
||||||
|
blocked_user = await self.check_user_blocked(session, telegram_id)
|
||||||
|
if not blocked_user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
blocked_user.is_active = False
|
||||||
|
blocked_user.last_attempt_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("User %s delivery reactivated", telegram_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send_with_guard(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
send_call: Callable[[], Awaitable[Any]],
|
||||||
|
*,
|
||||||
|
retry: bool = True,
|
||||||
|
skip_block_check: bool = False,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
"""
|
||||||
|
Execute a Telegram send call unless the recipient is known unavailable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telegram_id: recipient Telegram ID.
|
||||||
|
send_call: zero-argument async callable that performs the actual
|
||||||
|
aiogram send/copy operation and returns Telegram Message.
|
||||||
|
retry: retry once after TelegramRetryAfter when the delay is small.
|
||||||
|
skip_block_check: caller already filtered blocked users.
|
||||||
|
"""
|
||||||
|
if not skip_block_check:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
blocked_user = await self.check_user_blocked(session, telegram_id)
|
||||||
|
if blocked_user:
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
status=blocked_user.error_type,
|
||||||
|
skipped=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
telegram_message = await send_call()
|
||||||
|
except TelegramForbiddenError as exc:
|
||||||
|
await self._mark_unavailable(telegram_id, "blocked_bot", str(exc))
|
||||||
|
return DeliveryResult(success=False, status="blocked_bot")
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
error_type = self.classify_bad_request(exc)
|
||||||
|
if error_type:
|
||||||
|
await self._mark_unavailable(telegram_id, error_type, str(exc))
|
||||||
|
return DeliveryResult(success=False, status=error_type)
|
||||||
|
|
||||||
|
logger.warning("Telegram bad request for %s: %s", telegram_id, exc)
|
||||||
|
return DeliveryResult(success=False, status="bad_request")
|
||||||
|
except TelegramRetryAfter as exc:
|
||||||
|
if retry and exc.retry_after <= self.MAX_RETRY_AFTER:
|
||||||
|
logger.warning("FloodWait for %s: %s sec", telegram_id, exc.retry_after)
|
||||||
|
await asyncio.sleep(exc.retry_after + self.RETRY_AFTER_DELAY)
|
||||||
|
return await self.send_with_guard(
|
||||||
|
telegram_id,
|
||||||
|
send_call,
|
||||||
|
retry=False,
|
||||||
|
skip_block_check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning("Skipping %s after FloodWait %s sec", telegram_id, exc.retry_after)
|
||||||
|
return DeliveryResult(success=False, status="retry_after")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Telegram delivery error for %s: %s", telegram_id, exc)
|
||||||
|
return DeliveryResult(success=False, status="unknown_error")
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await self.unblock_user(session, telegram_id)
|
||||||
|
|
||||||
|
return DeliveryResult(success=True, telegram_message=telegram_message)
|
||||||
|
|
||||||
|
def classify_bad_request(self, exc: TelegramBadRequest) -> Optional[str]:
|
||||||
|
error_text = str(exc).lower()
|
||||||
|
for marker, error_type in self._BAD_REQUEST_ERROR_TYPES.items():
|
||||||
|
if marker in error_text:
|
||||||
|
return error_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _mark_unavailable(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
error_type: str,
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await self.mark_user_blocked(session, telegram_id, error_type, error_message)
|
||||||
|
|
||||||
|
|
||||||
|
delivery_service = DeliveryService()
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -36,27 +33,15 @@ 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)
|
||||||
try:
|
|
||||||
await self.session.commit()
|
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
|
||||||
|
|
||||||
@@ -73,7 +58,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.has(User.telegram_id == admin_id))
|
query = query.where(EmojiMapping.admin_id == admin_id)
|
||||||
|
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
@@ -89,7 +74,7 @@ class EmojiMappingService:
|
|||||||
EmojiMapping объект или None
|
EmojiMapping объект или None
|
||||||
"""
|
"""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(EmojiMapping).options(selectinload(EmojiMapping.admin)).where(EmojiMapping.emoji_id == emoji_id)
|
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@@ -104,14 +89,14 @@ class EmojiMappingService:
|
|||||||
Список EmojiMapping объектов
|
Список EmojiMapping объектов
|
||||||
"""
|
"""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(EmojiMapping).where(EmojiMapping.admin.has(User.telegram_id == admin_id))
|
select(EmojiMapping).where(EmojiMapping.admin_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).options(selectinload(EmojiMapping.admin)).order_by(EmojiMapping.created_at.desc())
|
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -151,39 +136,42 @@ 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:
|
# Экранируем специальные символы если нужно
|
||||||
mapping.setdefault(emoji.emoji_text, emoji.emoji_id)
|
if emoji.emoji_text in text:
|
||||||
if not mapping:
|
# Замена с сохранением контекста - оборачиваем в специальные маркеры
|
||||||
return escape(text)
|
# Это позволит потом распознать что это эмодзи ID а не обычный текст
|
||||||
pattern = re.compile("|".join(re.escape(key) for key in sorted(mapping, key=len, reverse=True)))
|
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
|
||||||
output, position = [], 0
|
|
||||||
for match in pattern.finditer(text):
|
return 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."""
|
"""
|
||||||
from html.parser import HTMLParser
|
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
|
||||||
|
|
||||||
class PlainText(HTMLParser):
|
Args:
|
||||||
def __init__(self):
|
text: Текст с emoji_id маркерами (|emoji_id|)
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self.parts = []
|
|
||||||
|
|
||||||
def handle_data(self, data):
|
Returns:
|
||||||
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
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Health reflects a live event loop and a usable database connection."""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
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 asyncio.timeout(20):
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await session.execute(text("SELECT 1"))
|
|
||||||
pending = HEARTBEAT.with_name(HEARTBEAT.name + ".tmp")
|
|
||||||
pending.write_text(str(time.time()), encoding="ascii")
|
|
||||||
pending.replace(HEARTBEAT)
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
|
|
||||||
|
|
||||||
def healthy():
|
|
||||||
try:
|
|
||||||
return 0 <= time.time() - float(HEARTBEAT.read_text(encoding="ascii")) < 60
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def start_watchdog(grace_seconds=75, interval=10):
|
|
||||||
"""Exit even when the event loop or driver cancellation itself is stuck."""
|
|
||||||
stopped = threading.Event()
|
|
||||||
started = time.monotonic()
|
|
||||||
|
|
||||||
def watch():
|
|
||||||
while not stopped.wait(interval):
|
|
||||||
if time.monotonic() - started >= grace_seconds and not healthy():
|
|
||||||
logging.getLogger(__name__).critical("Heartbeat expired; restarting the application")
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
threading.Thread(target=watch, name="heartbeat-watchdog", daemon=True).start()
|
|
||||||
return stopped
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(0 if healthy() else 1)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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, Index, text
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger, Index
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -8,6 +8,9 @@ import secrets
|
|||||||
class User(Base):
|
class User(Base):
|
||||||
"""Модель пользователя с регистрацией"""
|
"""Модель пользователя с регистрацией"""
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_users_registered_activity", "is_registered", "last_activity"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
||||||
@@ -19,7 +22,6 @@ 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)) # Последняя активность
|
||||||
@@ -116,9 +118,8 @@ class Participation(Base):
|
|||||||
"""Модель участия в розыгрыше"""
|
"""Модель участия в розыгрыше"""
|
||||||
__tablename__ = "participations"
|
__tablename__ = "participations"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("lottery_id", "account_number", name="uq_participation_account"),
|
Index("ix_participations_lottery_account", "lottery_id", "account_number"),
|
||||||
Index("uq_participation_user", "lottery_id", "user_id", unique=True,
|
Index("ix_participations_user_created", "user_id", "created_at"),
|
||||||
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)
|
||||||
@@ -142,7 +143,10 @@ class Participation(Base):
|
|||||||
class Winner(Base):
|
class Winner(Base):
|
||||||
"""Модель победителя розыгрыша"""
|
"""Модель победителя розыгрыша"""
|
||||||
__tablename__ = "winners"
|
__tablename__ = "winners"
|
||||||
__table_args__ = (UniqueConstraint("lottery_id", "place", name="uq_winner_place"),)
|
__table_args__ = (
|
||||||
|
Index("ix_winners_lottery_place", "lottery_id", "place"),
|
||||||
|
Index("ix_winners_user_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||||
@@ -278,6 +282,9 @@ class BroadcastChannel(Base):
|
|||||||
class BlockedUser(Base):
|
class BlockedUser(Base):
|
||||||
"""Пользователи, которые заблокировали бота или недоступны"""
|
"""Пользователи, которые заблокировали бота или недоступны"""
|
||||||
__tablename__ = "blocked_users"
|
__tablename__ = "blocked_users"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_blocked_users_lookup", "telegram_id", "error_type", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)
|
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
Система управления правами доступа к командам бота
|
Система управления правами доступа к командам бота
|
||||||
"""
|
"""
|
||||||
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):
|
||||||
@@ -164,7 +177,7 @@ def get_admin_commands_by_category():
|
|||||||
return commands_by_category
|
return commands_by_category
|
||||||
|
|
||||||
|
|
||||||
async def format_commands_help(user_id: int) -> str:
|
def format_commands_help(user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Форматировать справку по командам в зависимости от прав пользователя
|
Форматировать справку по командам в зависимости от прав пользователя
|
||||||
"""
|
"""
|
||||||
@@ -176,7 +189,7 @@ async def format_commands_help(user_id: int) -> str:
|
|||||||
help_text += f"/{cmd} - {info['description']}\n"
|
help_text += f"/{cmd} - {info['description']}\n"
|
||||||
|
|
||||||
# Если админ - показываем административные команды
|
# Если админ - показываем административные команды
|
||||||
if await is_admin(user_id):
|
if 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,9 +1,11 @@
|
|||||||
"""Compatibility helpers for emoji-aware HTML templates.
|
"""
|
||||||
|
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
|
||||||
|
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
|
||||||
|
|
||||||
Plain Unicode never identifies a custom emoji. Incoming chat and broadcast messages
|
Для использования премиум эмодзи:
|
||||||
use their original Telegram entities via src.utils.telegram_messages.
|
1. Бот должен быть создан с премиум аккаунта
|
||||||
Telegram decides availability from bot ownership/subscription or Fragment eligibility:
|
2. Использовать эмодзи напрямую в тексте сообщений
|
||||||
https://core.telegram.org/bots/api#formatting-options
|
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -14,7 +16,7 @@ from aiogram.enums import MessageEntityType
|
|||||||
class PremiumEmojiConfig:
|
class PremiumEmojiConfig:
|
||||||
"""Конфигурация поддержки премиум эмодзи"""
|
"""Конфигурация поддержки премиум эмодзи"""
|
||||||
|
|
||||||
# Поддержка в коде; этот флаг не проверяет права бота на стороне Telegram
|
# Флаг, что бот может использовать премиум эмодзи
|
||||||
SUPPORTS_PREMIUM_EMOJI = True
|
SUPPORTS_PREMIUM_EMOJI = True
|
||||||
|
|
||||||
# Стандартные parse_mode для автоматической поддержки эмодзи
|
# Стандартные parse_mode для автоматической поддержки эмодзи
|
||||||
@@ -58,7 +60,7 @@ def ensure_emoji_support(text: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Обработанный текст с поддержкой эмодзи
|
Обработанный текст с поддержкой эмодзи
|
||||||
"""
|
"""
|
||||||
# Уже подготовленные tg-emoji теги сохраняются; обычный символ не превращается в premium
|
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
|
||||||
# Эта функция может быть расширена для дополнительной обработки если нужно
|
# Эта функция может быть расширена для дополнительной обработки если нужно
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@@ -81,9 +83,7 @@ async def send_message_with_emoji(
|
|||||||
Returns:
|
Returns:
|
||||||
Результат отправки сообщения
|
Результат отправки сообщения
|
||||||
"""
|
"""
|
||||||
if kwargs.get("entities") is not None:
|
if parse_mode is None:
|
||||||
parse_mode = None
|
|
||||||
elif parse_mode is None:
|
|
||||||
parse_mode = get_parse_mode()
|
parse_mode = get_parse_mode()
|
||||||
|
|
||||||
# Убедиться что текст может содержать эмодзи
|
# Убедиться что текст может содержать эмодзи
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
"""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,10 +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
|
|
||||||
from src.utils.input_validation import club_card, phone_number
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from ..utils.account_utils import format_account_number
|
||||||
|
|
||||||
|
|
||||||
class RegistrationService:
|
class RegistrationService:
|
||||||
@@ -18,17 +16,12 @@ class RegistrationService:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
club_card_number: str,
|
club_card_number: str,
|
||||||
phone: Optional[str] = None,
|
phone: Optional[str] = None
|
||||||
nickname: Optional[str] = None,
|
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Зарегистрировать нового пользователя с клубной картой"""
|
"""Зарегистрировать нового пользователя с клубной картой"""
|
||||||
club_card_number = club_card(club_card_number)
|
|
||||||
phone = phone_number(phone)
|
|
||||||
if nickname is not None and not 2 <= len(nickname) <= 20:
|
|
||||||
raise ValueError("Никнейм должен содержать от 2 до 20 символов")
|
|
||||||
# Проверяем, не занята ли клубная карта
|
# Проверяем, не занята ли клубная карта
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(User).where(User.club_card_number == club_card_number, User.telegram_id != telegram_id)
|
select(User).where(User.club_card_number == club_card_number)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
|
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
|
||||||
@@ -41,22 +34,16 @@ class RegistrationService:
|
|||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError("Пользователь не найден")
|
raise ValueError("Пользователь не найден")
|
||||||
if user.is_registered:
|
|
||||||
raise ValueError("Вы уже зарегистрированы. Для изменения карты обратитесь к администратору.")
|
|
||||||
|
|
||||||
# Обновляем данные пользователя
|
# Обновляем данные пользователя
|
||||||
user.club_card_number = club_card_number
|
user.club_card_number = club_card_number
|
||||||
user.phone = phone
|
user.phone = phone
|
||||||
if nickname is not None:
|
|
||||||
user.nickname = nickname
|
|
||||||
user.is_registered = True
|
user.is_registered = True
|
||||||
user.generate_verification_code()
|
user.generate_verification_code()
|
||||||
|
|
||||||
try:
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
await session.refresh(user)
|
||||||
await session.rollback()
|
|
||||||
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -92,9 +79,10 @@ class AccountService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||||
account_number = format_account_number(account_number)
|
formatted_account = format_account_number(account_number)
|
||||||
if not account_number:
|
if not formatted_account:
|
||||||
raise ValueError("Некорректный номер счета: требуется 14 цифр")
|
raise ValueError(f"Некорректный формат счета: {account_number}")
|
||||||
|
|
||||||
# Находим владельца по клубной карте
|
# Находим владельца по клубной карте
|
||||||
user_result = await session.execute(
|
user_result = await session.execute(
|
||||||
select(User).where(User.club_card_number == club_card_number)
|
select(User).where(User.club_card_number == club_card_number)
|
||||||
@@ -106,23 +94,19 @@ class AccountService:
|
|||||||
|
|
||||||
# Проверяем, не существует ли уже такой счет
|
# Проверяем, не существует ли уже такой счет
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(Account).where(Account.account_number == account_number)
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise ValueError(f"Счет {account_number} уже существует")
|
raise ValueError(f"Счет {formatted_account} уже существует")
|
||||||
|
|
||||||
# Создаем счет
|
# Создаем счет
|
||||||
account = Account(
|
account = Account(
|
||||||
account_number=account_number,
|
account_number=formatted_account,
|
||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
session.add(account)
|
session.add(account)
|
||||||
try:
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
|
||||||
await session.rollback()
|
|
||||||
raise ValueError("Счет уже существует") from None
|
|
||||||
await session.refresh(account)
|
await session.refresh(account)
|
||||||
|
|
||||||
return account
|
return account
|
||||||
@@ -133,13 +117,14 @@ class AccountService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Найти владельца счета"""
|
"""Найти владельца счета"""
|
||||||
account_number = format_account_number(account_number)
|
formatted_account = format_account_number(account_number)
|
||||||
if not account_number:
|
if not formatted_account:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Account).where(
|
select(Account).where(
|
||||||
and_(
|
and_(
|
||||||
Account.account_number == account_number,
|
Account.account_number == formatted_account,
|
||||||
Account.is_active == True
|
Account.is_active == True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -174,8 +159,12 @@ class AccountService:
|
|||||||
account_number: str
|
account_number: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Деактивировать счет"""
|
"""Деактивировать счет"""
|
||||||
|
formatted_account = format_account_number(account_number)
|
||||||
|
if not formatted_account:
|
||||||
|
return False
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Account).where(Account.account_number == account_number)
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
)
|
)
|
||||||
account = result.scalar_one_or_none()
|
account = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -225,33 +214,74 @@ class WinnerNotificationService:
|
|||||||
return verification
|
return verification
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def verify_winner(session, verification_code, lottery_id):
|
async def verify_winner(
|
||||||
"""An atomic conditional update prevents two cashiers claiming the same prize."""
|
session: AsyncSession,
|
||||||
from sqlalchemy import update, or_
|
verification_code: str,
|
||||||
from .models import Lottery
|
lottery_id: int
|
||||||
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == lottery_id)):
|
) -> Optional[Winner]:
|
||||||
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_id = await session.scalar(select(Winner.id).where(
|
winner_result = await session.execute(
|
||||||
Winner.lottery_id == lottery_id, Winner.is_claimed.is_(False),
|
select(Winner).where(
|
||||||
or_(Winner.user_id == user.id, and_(Winner.user_id.is_(None), Winner.account_number.in_(accounts)))
|
and_(
|
||||||
).order_by(Winner.place).limit(1))
|
Winner.user_id == user.id,
|
||||||
if winner_id is None:
|
Winner.lottery_id == lottery_id,
|
||||||
|
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))
|
# Помечаем как подтвержденный
|
||||||
.values(is_claimed=True, claimed_at=now))
|
winner.is_claimed = True
|
||||||
if changed.rowcount != 1:
|
|
||||||
await session.rollback()
|
# Обновляем верификацию если есть
|
||||||
return None
|
verification_result = await session.execute(
|
||||||
await session.execute(update(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
|
select(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()
|
||||||
return await session.get(Winner, winner_id)
|
await session.refresh(winner)
|
||||||
|
|
||||||
|
return winner
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_unverified_winners(
|
async def get_unverified_winners(
|
||||||
|
|||||||
@@ -1,38 +1,54 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update, delete
|
from sqlalchemy import select, update, delete, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from .models import User, Lottery, Participation, Winner, Account
|
from .models import User, Lottery, Participation, Winner, Account
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from ..utils.account_utils import validate_account_number, format_account_number
|
from ..utils.account_utils import validate_account_number, format_account_number
|
||||||
import random
|
import random
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
class UserService:
|
||||||
"""Сервис для работы с пользователями"""
|
"""Сервис для работы с пользователями"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_user(session, telegram_id, username=None, first_name=None, last_name=None, nickname=None):
|
async def get_or_create_user(session: AsyncSession, telegram_id: int,
|
||||||
"""Upsert without racing or erasing metadata when only an ID is supplied."""
|
username: str = None, first_name: str = None,
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
last_name: str = None, nickname: str = None) -> User:
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
"""Получить или создать пользователя"""
|
||||||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
# Пробуем найти существующего пользователя
|
||||||
values = {key: value for key, value in dict(username=username, first_name=first_name,
|
result = await session.execute(
|
||||||
last_name=last_name, nickname=nickname).items() if value is not None}
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
stmt = insert(User).values(telegram_id=telegram_id, **values)
|
)
|
||||||
if values:
|
user = result.scalar_one_or_none()
|
||||||
stmt = stmt.on_conflict_do_update(index_elements=[User.telegram_id], set_=values)
|
|
||||||
else:
|
if user:
|
||||||
stmt = stmt.on_conflict_do_nothing(index_elements=[User.telegram_id])
|
# Обновляем информацию о пользователе
|
||||||
await session.execute(stmt)
|
user.username = username
|
||||||
|
user.first_name = first_name
|
||||||
|
user.last_name = last_name
|
||||||
|
# Обновляем nickname только если он передан
|
||||||
|
if nickname is not None:
|
||||||
|
user.nickname = nickname
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return (await session.scalars(select(User).options(selectinload(User.accounts))
|
return user
|
||||||
.where(User.telegram_id == telegram_id).execution_options(populate_existing=True))).one()
|
|
||||||
|
# Создаем нового пользователя
|
||||||
|
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.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
@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]:
|
||||||
"""Получить пользователя по Telegram ID"""
|
"""Получить пользователя по Telegram ID"""
|
||||||
if not isinstance(telegram_id, int) or not 0 < telegram_id < 2**63:
|
|
||||||
return None
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(User.telegram_id == telegram_id)
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
)
|
)
|
||||||
@@ -82,23 +98,10 @@ class UserService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_user(session: AsyncSession, user_id: int) -> bool:
|
async def delete_user(session: AsyncSession, user_id: int) -> bool:
|
||||||
"""Удалить пользователя и все связанные данные"""
|
"""Удалить пользователя и все связанные данные"""
|
||||||
user = await session.scalar(select(User).where(User.id == user_id).with_for_update()
|
user = await session.get(User, user_id)
|
||||||
.execution_options(populate_existing=True))
|
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from .config import ADMIN_IDS, CASHIER_IDS
|
|
||||||
if user.is_admin or user.is_cashier or user.telegram_id in set(ADMIN_IDS) | set(CASHIER_IDS):
|
|
||||||
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)
|
||||||
@@ -115,41 +118,111 @@ class UserService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def set_account_number(session, telegram_id, account_number):
|
async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool:
|
||||||
from sqlalchemy.exc import IntegrityError
|
"""Установить/снять права администратора"""
|
||||||
formatted = format_account_number(account_number)
|
result = await session.execute(
|
||||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
update(User)
|
||||||
if not formatted or not user:
|
.where(User.telegram_id == telegram_id)
|
||||||
return False
|
.values(is_admin=is_admin)
|
||||||
if await session.scalar(select(Account.id).where(Account.account_number == formatted)):
|
)
|
||||||
return False
|
|
||||||
session.add(Account(account_number=formatted, owner_id=user.id))
|
|
||||||
try:
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
return result.rowcount > 0
|
||||||
await session.rollback()
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
|
||||||
|
"""Привязать номер клиентского счета к пользователю.
|
||||||
|
|
||||||
|
Исторически номер счета хранился в User.account_number. В актуальной
|
||||||
|
схеме счета живут в отдельной таблице Account, поэтому метод оставлен
|
||||||
|
как совместимый фасад для старых обработчиков и тестов.
|
||||||
|
"""
|
||||||
|
# Валидируем и форматируем номер
|
||||||
|
formatted_number = format_account_number(account_number)
|
||||||
|
if not formatted_number:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
existing = await session.execute(
|
||||||
|
select(Account).where(Account.account_number == formatted_number)
|
||||||
|
)
|
||||||
|
account = existing.scalar_one_or_none()
|
||||||
|
if account:
|
||||||
|
if account.owner_id != user.id:
|
||||||
|
return False
|
||||||
|
account.is_active = True
|
||||||
|
else:
|
||||||
|
session.add(Account(
|
||||||
|
account_number=formatted_number,
|
||||||
|
owner_id=user.id,
|
||||||
|
is_active=True
|
||||||
|
))
|
||||||
|
|
||||||
|
user.account_number = formatted_number
|
||||||
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_account(session, account_number):
|
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
|
||||||
formatted = format_account_number(account_number)
|
"""Получить пользователя по номеру счета"""
|
||||||
if not formatted:
|
formatted_number = format_account_number(account_number)
|
||||||
|
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)))
|
result = await session.execute(
|
||||||
|
select(User, Account.account_number)
|
||||||
|
.join(Account, Account.owner_id == User.id)
|
||||||
|
.where(
|
||||||
|
Account.account_number == formatted_number,
|
||||||
|
Account.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.one_or_none()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user, account = row
|
||||||
|
user.account_number = account
|
||||||
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_club_card(session, club_card_number):
|
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||||
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()
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def search_by_account(session, account_pattern):
|
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
|
||||||
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()
|
|
||||||
.where(Account.account_number.contains(clean, autoescape=True)).limit(20))).all())
|
result = await session.execute(
|
||||||
|
select(User, Account.account_number)
|
||||||
|
.join(Account, Account.owner_id == User.id)
|
||||||
|
.where(Account.account_number.like(f'%{clean_pattern}%'))
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
users = []
|
||||||
|
for user, account_number in result.all():
|
||||||
|
user.account_number = account_number
|
||||||
|
users.append(user)
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
class LotteryService:
|
class LotteryService:
|
||||||
@@ -159,11 +232,10 @@ class LotteryService:
|
|||||||
async def create_lottery(session: AsyncSession, title: str, description: str,
|
async def create_lottery(session: AsyncSession, title: str, description: str,
|
||||||
prizes: List[str], creator_id: int) -> Lottery:
|
prizes: List[str], creator_id: int) -> Lottery:
|
||||||
"""Создать новый розыгрыш"""
|
"""Создать новый розыгрыш"""
|
||||||
from src.utils.input_validation import lottery_field
|
|
||||||
lottery = Lottery(
|
lottery = Lottery(
|
||||||
title=lottery_field("title", title),
|
title=title,
|
||||||
description=lottery_field("description", description),
|
description=description,
|
||||||
prizes=lottery_field("prizes", prizes),
|
prizes=prizes,
|
||||||
creator_id=creator_id
|
creator_id=creator_id
|
||||||
)
|
)
|
||||||
session.add(lottery)
|
session.add(lottery)
|
||||||
@@ -174,13 +246,10 @@ class LotteryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
|
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
|
||||||
"""Получить розыгрыш по ID"""
|
"""Получить розыгрыш по ID"""
|
||||||
if not isinstance(lottery_id, int) or not 0 < lottery_id < 2**31:
|
|
||||||
return None
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
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()
|
||||||
|
|
||||||
@@ -205,19 +274,14 @@ 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:
|
||||||
from src.utils.input_validation import lottery_field
|
await session.execute(
|
||||||
updates = {key: lottery_field(key, value) if key in {"title", "description", "prizes"} else value
|
|
||||||
for key, value in updates.items()}
|
|
||||||
result = await session.execute(
|
|
||||||
update(Lottery)
|
update(Lottery)
|
||||||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
.where(Lottery.id == lottery_id)
|
||||||
.values(**updates)
|
.values(**updates)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount > 0
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return False
|
return False
|
||||||
@@ -234,90 +298,136 @@ class LotteryService:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def set_manual_winner(session, lottery_id, place, telegram_id):
|
async def set_manual_winner(session: AsyncSession, lottery_id: int,
|
||||||
from .transactions import lock_open_lottery
|
place: int, telegram_id: int) -> bool:
|
||||||
if not await lock_open_lottery(session, lottery_id):
|
"""Установить ручного победителя для определенного места"""
|
||||||
await session.rollback()
|
# Получаем пользователя
|
||||||
return False
|
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
|
||||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
if not user or place < 1 or place > len(lottery.prizes or [None]):
|
if not user:
|
||||||
await session.rollback()
|
|
||||||
return False
|
return False
|
||||||
if not any(p.user_id == user.id for p in lottery.participations):
|
|
||||||
await session.rollback()
|
# Получаем розыгрыш
|
||||||
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
if not lottery:
|
||||||
return False
|
return False
|
||||||
# Reassign JSON: in-place edits are not tracked by a plain JSON column.
|
|
||||||
winners = dict(lottery.manual_winners or {})
|
# Обновляем ручных победителей
|
||||||
if telegram_id in (value for key, value in winners.items() if key != str(place)):
|
if not lottery.manual_winners:
|
||||||
await session.rollback()
|
lottery.manual_winners = {}
|
||||||
return False
|
|
||||||
winners[str(place)] = telegram_id
|
lottery.manual_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, lottery_id):
|
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
|
||||||
"""Select and persist winners once, in one transaction, before notifications."""
|
"""Провести розыгрыш с учетом ручных победителей"""
|
||||||
from types import SimpleNamespace
|
import logging
|
||||||
from datetime import datetime, timezone
|
logger = logging.getLogger(__name__)
|
||||||
from .transactions import lock_open_lottery
|
|
||||||
from .models import WinnerVerification
|
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
|
||||||
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)
|
||||||
participations = list(lottery.participations)
|
if not lottery or lottery.is_completed:
|
||||||
if not participations:
|
logger.warning(f"conduct_draw: lottery не найден или завершён")
|
||||||
await session.rollback()
|
|
||||||
return {}
|
return {}
|
||||||
num_prizes = len(lottery.prizes or [None])
|
|
||||||
existing = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id))).all())
|
logger.info(f"conduct_draw: получаем участников")
|
||||||
if any(w.is_claimed for w in existing):
|
# Получаем всех участников (включая тех, у кого нет user)
|
||||||
raise ValueError("В розыгрыше уже есть выданные призы")
|
participants = []
|
||||||
selected = {}
|
for p in lottery.participations:
|
||||||
available = participations.copy()
|
if p.user:
|
||||||
|
participants.append(SimpleNamespace(
|
||||||
|
id=p.user.id,
|
||||||
|
telegram_id=p.user.telegram_id,
|
||||||
|
username=p.user.username,
|
||||||
|
first_name=p.user.first_name,
|
||||||
|
last_name=p.user.last_name,
|
||||||
|
account_number=p.account_number
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Создаем временный объект для участников без пользователя
|
||||||
|
# Храним только номер счета
|
||||||
|
participants.append(SimpleNamespace(
|
||||||
|
id=None,
|
||||||
|
telegram_id=None,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
last_name=None,
|
||||||
|
account_number=p.account_number
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info(f"conduct_draw: участников {len(participants)}")
|
||||||
|
if not participants:
|
||||||
|
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):
|
||||||
preset = next((w for w in existing if w.place == place and w.is_manual), None)
|
place_str = str(place)
|
||||||
manual = (lottery.manual_winners or {}).get(str(place))
|
if place_str in manual_winners:
|
||||||
candidate = next((p for p in available if
|
# Находим пользователя среди участников
|
||||||
(preset and preset.account_number and p.account_number == preset.account_number) or
|
manual_winner = None
|
||||||
(manual and p.user and p.user.telegram_id == manual)), None)
|
for participant in remaining_participants:
|
||||||
if candidate:
|
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
|
||||||
selected[place] = (candidate, True)
|
manual_winner = participant
|
||||||
available.remove(candidate)
|
break
|
||||||
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 selected and available:
|
if place not in results and remaining_participants:
|
||||||
candidate = rng.choice(available)
|
winner = random.choice(remaining_participants)
|
||||||
available.remove(candidate)
|
results[place] = {
|
||||||
selected[place] = (candidate, False)
|
'user': winner,
|
||||||
if existing:
|
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
|
||||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_([w.id for w in existing])))
|
'is_manual': False
|
||||||
await session.execute(delete(Winner).where(Winner.lottery_id == lottery_id))
|
}
|
||||||
results, stored = {}, {}
|
remaining_participants.remove(winner)
|
||||||
for place, (participation, manual) in sorted(selected.items()):
|
|
||||||
owner = participation.user
|
# Сохраняем победителей в базу данных
|
||||||
# Preserve the selected ticket, including for users with multiple accounts.
|
for place, winner_info in results.items():
|
||||||
actor = SimpleNamespace(id=participation.user_id,
|
user_obj = winner_info['user']
|
||||||
telegram_id=owner.telegram_id if owner else None,
|
winner = Winner(
|
||||||
username=owner.username if owner else None,
|
lottery_id=lottery_id,
|
||||||
first_name=owner.first_name if owner else None,
|
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
||||||
nickname=owner.nickname if owner else None,
|
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
||||||
account_number=participation.account_number)
|
place=place,
|
||||||
prize = lottery.prizes[place - 1] if lottery.prizes else f"Приз {place} места"
|
prize=winner_info['prize'],
|
||||||
session.add(Winner(lottery_id=lottery_id, user_id=actor.id, account_number=actor.account_number,
|
is_manual=winner_info['is_manual']
|
||||||
place=place, prize=prize, is_manual=manual))
|
)
|
||||||
results[place] = dict(user=actor, prize=prize, is_manual=manual)
|
session.add(winner)
|
||||||
stored[str(place)] = dict(user_id=actor.id, telegram_id=actor.telegram_id,
|
|
||||||
username=actor.username, account_number=actor.account_number,
|
# Обновляем статус розыгрыша
|
||||||
prize=prize, is_manual=manual)
|
logger.info(f"conduct_draw: обновляем статус lottery")
|
||||||
lottery.is_completed = True
|
lottery.is_completed = True
|
||||||
lottery.is_active = False
|
lottery.draw_results = {}
|
||||||
lottery.end_date = datetime.now(timezone.utc)
|
for place, info in results.items():
|
||||||
lottery.draw_results = stored
|
user_obj = info['user']
|
||||||
await session.commit()
|
lottery.draw_results[str(place)] = {
|
||||||
|
'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
|
||||||
@@ -352,7 +462,7 @@ class LotteryService:
|
|||||||
"""Установить статус активности розыгрыша"""
|
"""Установить статус активности розыгрыша"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(Lottery)
|
update(Lottery)
|
||||||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
.where(Lottery.id == lottery_id)
|
||||||
.values(is_active=is_active)
|
.values(is_active=is_active)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -374,9 +484,6 @@ 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(
|
||||||
@@ -401,42 +508,56 @@ class ParticipationService:
|
|||||||
"""Сервис для работы с участием в розыгрышах"""
|
"""Сервис для работы с участием в розыгрышах"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participant(session, lottery_id, user_id):
|
async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
||||||
from .transactions import lock_open_lottery
|
"""Добавить участника в розыгрыш"""
|
||||||
if not await lock_open_lottery(session, lottery_id):
|
# Проверяем, не участвует ли уже пользователь
|
||||||
await session.rollback()
|
existing = await session.execute(
|
||||||
|
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.lottery_id == lottery_id, Participation.user_id == user_id).limit(1))
|
participation = Participation(lottery_id=lottery_id, user_id=user_id)
|
||||||
user = await session.get(User, user_id)
|
session.add(participation)
|
||||||
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, lottery_id, user_id):
|
async def remove_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
||||||
from .transactions import lock_open_lottery
|
"""Удалить участника из розыгрыша"""
|
||||||
if not await lock_open_lottery(session, lottery_id):
|
participation = await session.execute(
|
||||||
await session.rollback()
|
select(Participation)
|
||||||
|
.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(
|
|
||||||
Participation.lottery_id == lottery_id, Participation.user_id == user_id))
|
await session.delete(participation)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount > 0
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||||||
"""Получить участников розыгрыша"""
|
"""Получить участников розыгрыша"""
|
||||||
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id)
|
query = (
|
||||||
|
select(Participation)
|
||||||
|
.options(selectinload(Participation.user))
|
||||||
|
.where(Participation.lottery_id == lottery_id)
|
||||||
|
)
|
||||||
|
|
||||||
if limit:
|
if limit:
|
||||||
query = query.offset(offset).limit(limit)
|
query = query.offset(offset).limit(limit)
|
||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
return list(result.scalars().all())
|
participants = []
|
||||||
|
for participation in result.scalars().all():
|
||||||
|
if participation.user:
|
||||||
|
participation.user.account_number = participation.account_number
|
||||||
|
participants.append(participation.user)
|
||||||
|
return participants
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
|
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
|
||||||
@@ -450,9 +571,11 @@ class ParticipationService:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participants_count(session, lottery_id):
|
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
|
||||||
from sqlalchemy import func
|
"""Получить количество участников в розыгрыше"""
|
||||||
return await session.scalar(select(func.count()).select_from(Participation).where(Participation.lottery_id == lottery_id))
|
return await session.scalar(
|
||||||
|
select(func.count(Participation.id)).where(Participation.lottery_id == lottery_id)
|
||||||
|
)
|
||||||
|
|
||||||
@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]:
|
||||||
@@ -472,19 +595,16 @@ class ParticipationService:
|
|||||||
results["errors"].append(f"Пользователь {telegram_id} не найден")
|
results["errors"].append(f"Пользователь {telegram_id} не найден")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first_name, username = user.first_name, user.username
|
|
||||||
# Пробуем добавить
|
# Пробуем добавить
|
||||||
if await ParticipationService.add_participant(session, lottery_id, user.id):
|
if await ParticipationService.add_participant(session, lottery_id, user.id):
|
||||||
results["added"] += 1
|
results["added"] += 1
|
||||||
results["details"].append(f"Добавлен: {first_name} (@{username or 'no_username'})")
|
results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})")
|
||||||
else:
|
else:
|
||||||
results["skipped"] += 1
|
results["skipped"] += 1
|
||||||
results["details"].append(f"Не добавлен (повтор или розыгрыш закрыт): {first_name}")
|
results["details"].append(f"Уже участвует: {user.first_name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||||||
from src.utils.errors import public_error
|
|
||||||
results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}")
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -506,46 +626,199 @@ class ParticipationService:
|
|||||||
results["details"].append(f"Не найден: {telegram_id}")
|
results["details"].append(f"Не найден: {telegram_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first_name = user.first_name
|
|
||||||
if await ParticipationService.remove_participant(session, lottery_id, user.id):
|
if await ParticipationService.remove_participant(session, lottery_id, user.id):
|
||||||
results["removed"] += 1
|
results["removed"] += 1
|
||||||
results["details"].append(f"Удален: {first_name}")
|
results["details"].append(f"Удален: {user.first_name}")
|
||||||
else:
|
else:
|
||||||
results["not_found"] += 1
|
results["not_found"] += 1
|
||||||
results["details"].append(f"Не удалён (нет участия или розыгрыш закрыт): {first_name}")
|
results["details"].append(f"Не участвовал: {user.first_name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||||||
from src.utils.errors import public_error
|
|
||||||
results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}")
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def add_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||||
from src.handlers.account_services import AccountParticipationService
|
"""Массовое добавление участников по номерам счетов"""
|
||||||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, account_numbers)
|
results = {
|
||||||
result['invalid_accounts'] = [a for a in account_numbers if not a.split() or not format_account_number(a.split()[-1])]
|
"added": 0,
|
||||||
return result
|
"skipped": 0,
|
||||||
|
"errors": [],
|
||||||
|
"details": [],
|
||||||
|
"invalid_accounts": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for account_input in account_numbers:
|
||||||
|
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["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)
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
if results["added"]:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def remove_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
async def remove_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||||
from src.handlers.account_services import AccountParticipationService
|
"""Массовое удаление участников по номерам счетов"""
|
||||||
result = dict(removed=0, not_found=0, errors=[], details=[], invalid_accounts=[])
|
results = {
|
||||||
for account in account_numbers:
|
"removed": 0,
|
||||||
value = account.split()[-1] if account.split() else ''
|
"not_found": 0,
|
||||||
item = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, value)
|
"errors": [],
|
||||||
result['removed' if item['success'] else 'not_found'] += 1
|
"details": [],
|
||||||
result['details'].append(item['message'])
|
"invalid_accounts": []
|
||||||
if not item['success']:
|
}
|
||||||
result['errors'].append(item['message'])
|
|
||||||
return result
|
for account_input in account_numbers:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
if results["removed"]:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||||||
"""Статистика участника"""
|
"""Статистика участника"""
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
# Количество участий
|
# Количество участий
|
||||||
participations_count = await session.scalar(
|
participations_count = await session.scalar(
|
||||||
select(func.count(Participation.id)).where(Participation.user_id == user_id)
|
select(func.count(Participation.id)).where(Participation.user_id == user_id)
|
||||||
@@ -568,3 +841,41 @@ class ParticipationService:
|
|||||||
"wins_count": wins_count,
|
"wins_count": wins_count,
|
||||||
"last_participation": last_participation.created_at if last_participation else None
|
"last_participation": last_participation.created_at if last_participation else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_participant_stats_bulk(session: AsyncSession, user_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""Статистика участников одним набором агрегирующих запросов."""
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
user_id: {
|
||||||
|
"participations_count": 0,
|
||||||
|
"wins_count": 0,
|
||||||
|
"last_participation": None,
|
||||||
|
}
|
||||||
|
for user_id in user_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
participations = await session.execute(
|
||||||
|
select(
|
||||||
|
Participation.user_id,
|
||||||
|
func.count(Participation.id),
|
||||||
|
func.max(Participation.created_at),
|
||||||
|
)
|
||||||
|
.where(Participation.user_id.in_(user_ids))
|
||||||
|
.group_by(Participation.user_id)
|
||||||
|
)
|
||||||
|
for user_id, count, last_participation in participations.all():
|
||||||
|
stats[user_id]["participations_count"] = count
|
||||||
|
stats[user_id]["last_participation"] = last_participation
|
||||||
|
|
||||||
|
wins = await session.execute(
|
||||||
|
select(Winner.user_id, func.count(Winner.id))
|
||||||
|
.where(Winner.user_id.in_(user_ids))
|
||||||
|
.group_by(Winner.user_id)
|
||||||
|
)
|
||||||
|
for user_id, count in wins.all():
|
||||||
|
stats[user_id]["wins_count"] = count
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"""System-admin-only staff changes with optimistic concurrency protection."""
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
|
|
||||||
from .config import ADMIN_IDS, CASHIER_IDS
|
|
||||||
from .database import async_session_maker
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
ROLE_NAMES = {"user": "Пользователь", "cashier": "Кассир", "admin": "Администратор",
|
|
||||||
"super_admin": "Системный администратор"}
|
|
||||||
|
|
||||||
|
|
||||||
class StaffError(ValueError):
|
|
||||||
"""An operator-facing error; no database details."""
|
|
||||||
|
|
||||||
|
|
||||||
def require_system_admin(actor_id):
|
|
||||||
if actor_id not in ADMIN_IDS:
|
|
||||||
raise StaffError("Назначать сотрудников может только системный администратор.")
|
|
||||||
|
|
||||||
|
|
||||||
def effective_role(telegram_id, admin=False, cashier=False):
|
|
||||||
if telegram_id in ADMIN_IDS:
|
|
||||||
return "super_admin"
|
|
||||||
if admin:
|
|
||||||
return "admin"
|
|
||||||
if cashier or telegram_id in CASHIER_IDS:
|
|
||||||
return "cashier"
|
|
||||||
return "user"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StaffMember:
|
|
||||||
telegram_id: int
|
|
||||||
name: str
|
|
||||||
role: str
|
|
||||||
flags: tuple[bool, bool]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_member(actor_id, telegram_id):
|
|
||||||
require_system_admin(actor_id)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
|
|
||||||
if user is None:
|
|
||||||
raise StaffError("Пользователь не найден. Сначала он должен отправить боту /start.")
|
|
||||||
flags = (bool(user.is_admin), bool(user.is_cashier))
|
|
||||||
return StaffMember(telegram_id, user.nickname or user.first_name or user.username or "Без имени",
|
|
||||||
effective_role(telegram_id, *flags), flags)
|
|
||||||
|
|
||||||
|
|
||||||
def check_editable(actor_id, telegram_id):
|
|
||||||
require_system_admin(actor_id)
|
|
||||||
if telegram_id in ADMIN_IDS:
|
|
||||||
raise StaffError("Права системного администратора нельзя изменить через бот.")
|
|
||||||
if telegram_id in CASHIER_IDS:
|
|
||||||
raise StaffError("Роль этого сотрудника закреплена в настройках сервера. Обратитесь к ответственному за сервер.")
|
|
||||||
|
|
||||||
|
|
||||||
async def change_role(actor_id, telegram_id, role, expected_flags):
|
|
||||||
check_editable(actor_id, telegram_id)
|
|
||||||
if role not in {"user", "admin", "cashier"} or len(expected_flags) != 2:
|
|
||||||
raise StaffError("Недопустимая роль.")
|
|
||||||
# Compare and change in one statement: a second operator cannot overwrite
|
|
||||||
# a role changed since the confirmation screen was opened.
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
result = await session.execute(update(User).where(
|
|
||||||
User.telegram_id == telegram_id,
|
|
||||||
func.coalesce(User.is_admin, False) == bool(expected_flags[0]),
|
|
||||||
User.is_cashier == bool(expected_flags[1]),
|
|
||||||
).values(is_admin=role == "admin", is_cashier=role == "cashier"))
|
|
||||||
if result.rowcount != 1:
|
|
||||||
await session.rollback()
|
|
||||||
raise StaffError("Права уже изменены другим администратором или пользователь удалён. Откройте назначение заново.")
|
|
||||||
await session.commit()
|
|
||||||
logger.warning("Staff role changed: actor=%s target=%s previous_flags=%s role=%s",
|
|
||||||
actor_id, telegram_id, tuple(expected_flags), role)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_members(actor_id, offset=0, limit=20):
|
|
||||||
require_system_admin(actor_id)
|
|
||||||
configured = set(ADMIN_IDS) | set(CASHIER_IDS)
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
rows = (await session.scalars(select(User).where(
|
|
||||||
User.is_admin.is_(True) | User.is_cashier.is_(True) | User.telegram_id.in_(configured)
|
|
||||||
).order_by(User.telegram_id))).all()
|
|
||||||
members = {u.telegram_id: StaffMember(u.telegram_id, u.nickname or u.first_name or u.username or "Без имени",
|
|
||||||
effective_role(u.telegram_id, u.is_admin, u.is_cashier), (bool(u.is_admin), bool(u.is_cashier))) for u in rows}
|
|
||||||
for telegram_id in configured - members.keys():
|
|
||||||
members[telegram_id] = StaffMember(telegram_id, "Ещё не запускал бот",
|
|
||||||
effective_role(telegram_id), (False, False))
|
|
||||||
ordered = sorted(members.values(), key=lambda member: member.telegram_id)
|
|
||||||
return ordered[offset:offset + limit], len(ordered)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""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,4 +1,3 @@
|
|||||||
from html import escape
|
|
||||||
"""
|
"""
|
||||||
Сервис управления пользователями с поиском и пагинацией
|
Сервис управления пользователями с поиском и пагинацией
|
||||||
"""
|
"""
|
||||||
@@ -68,7 +67,7 @@ class UserManagementService:
|
|||||||
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
|
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
|
||||||
|
|
||||||
# Если запрос - число, ищем по telegram_id
|
# Если запрос - число, ищем по telegram_id
|
||||||
if query.isascii() and query.isdigit() and len(query) <= 19 and 0 < int(query) < 2**63:
|
if query.isdigit():
|
||||||
search_conditions.append(User.telegram_id == int(query))
|
search_conditions.append(User.telegram_id == int(query))
|
||||||
|
|
||||||
conditions.append(or_(*search_conditions))
|
conditions.append(or_(*search_conditions))
|
||||||
@@ -160,9 +159,6 @@ 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
|
||||||
@@ -219,13 +215,13 @@ class UserManagementService:
|
|||||||
str: Форматированная информация
|
str: Форматированная информация
|
||||||
"""
|
"""
|
||||||
# Базовая информация
|
# Базовая информация
|
||||||
info = f"👤 <b>{escape(user.first_name or '')}"
|
info = f"👤 <b>{user.first_name}"
|
||||||
if user.last_name:
|
if user.last_name:
|
||||||
info += f" {escape(user.last_name or '')}"
|
info += f" {user.last_name}"
|
||||||
info += "</b>"
|
info += "</b>"
|
||||||
|
|
||||||
if user.username:
|
if user.username:
|
||||||
info += f" (@{escape(user.username or '')})"
|
info += f" (@{user.username})"
|
||||||
|
|
||||||
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
||||||
|
|
||||||
@@ -244,19 +240,16 @@ class UserManagementService:
|
|||||||
# Детальная информация
|
# Детальная информация
|
||||||
if detailed:
|
if detailed:
|
||||||
if user.nickname:
|
if user.nickname:
|
||||||
info += f"\n📝 Никнейм: {escape(user.nickname or '')}"
|
info += f"\n📝 Никнейм: {user.nickname}"
|
||||||
if user.club_card_number:
|
if user.club_card_number:
|
||||||
info += f"\n🎫 Клубная карта: <code>{escape(user.club_card_number or '')}</code>"
|
info += f"\n🎫 Клубная карта: <code>{user.club_card_number}</code>"
|
||||||
if user.phone:
|
if user.phone:
|
||||||
info += f"\n📞 Телефон: <code>{escape(user.phone or '')}</code>"
|
info += f"\n📞 Телефон: <code>{user.phone}</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:
|
||||||
last_activity = user.last_activity
|
days_inactive = (datetime.now(timezone.utc) - user.last_activity).days
|
||||||
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} дн. назад)"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from ..core.database import async_session_maker
|
from ..core.database import async_session_maker
|
||||||
from ..core.services import LotteryService, ParticipationService
|
from ..core.services import LotteryService, ParticipationService
|
||||||
from ..core.models import Lottery, User
|
|
||||||
|
|
||||||
async def conduct_lottery_draw(lottery_id: int):
|
async def conduct_lottery_draw(lottery_id: int):
|
||||||
"""Проводим розыгрыш для указанного ID"""
|
"""Проводим розыгрыш для указанного ID"""
|
||||||
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
|
|||||||
# Выводим список участников
|
# Выводим список участников
|
||||||
print("\n👥 Список участников:")
|
print("\n👥 Список участников:")
|
||||||
for i, user in enumerate(participants, 1):
|
for i, user in enumerate(participants, 1):
|
||||||
accounts = [p.account_number for p in lottery.participations if p.user_id == user.id and p.account_number]
|
username = f"@{user.username}" if user.username else "без username"
|
||||||
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
print(f" {i}. {user.first_name or 'Без имени'} ({username})")
|
||||||
|
|
||||||
# Проводим розыгрыш
|
# Проводим розыгрыш
|
||||||
print(f"\n🎲 Проводим розыгрыш...")
|
print(f"\n🎲 Проводим розыгрыш...")
|
||||||
@@ -48,13 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
|
|||||||
|
|
||||||
if winners:
|
if winners:
|
||||||
print(f"\n🎉 Победители определены:")
|
print(f"\n🎉 Победители определены:")
|
||||||
for i, winner_data in winners.items():
|
for place, winner_data in winners.items():
|
||||||
user = winner_data['user']
|
user = winner_data['user']
|
||||||
prize = winner_data['prize']
|
prize = winner_data['prize']
|
||||||
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
name = user.first_name or getattr(user, "account_number", None) or "Неизвестный участник"
|
||||||
|
username = f" (@{user.username})" if getattr(user, 'username', None) else ""
|
||||||
|
print(f" 🏆 {place} место: {name}{username}")
|
||||||
print(f" 💎 Приз: {prize}")
|
print(f" 💎 Приз: {prize}")
|
||||||
|
|
||||||
# Обновляем статус розыгрыша
|
await session.commit()
|
||||||
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def demo_admin_features():
|
|||||||
first_name="Администратор",
|
first_name="Администратор",
|
||||||
last_name="Системы"
|
last_name="Системы"
|
||||||
)
|
)
|
||||||
# Staff roles are assigned by a system administrator through /staff.
|
await UserService.set_admin(session, 123456789, True)
|
||||||
print(f"✅ Создан администратор: {admin.first_name}")
|
print(f"✅ Создан администратор: {admin.first_name}")
|
||||||
|
|
||||||
# Создаем тестовых пользователей
|
# Создаем тестовых пользователей
|
||||||
@@ -127,6 +127,7 @@ async def demo_admin_features():
|
|||||||
|
|
||||||
# Первый розыгрыш
|
# Первый розыгрыш
|
||||||
results1 = await LotteryService.conduct_draw(session, lottery1.id)
|
results1 = await LotteryService.conduct_draw(session, lottery1.id)
|
||||||
|
await session.commit()
|
||||||
print(f" 🎉 {lottery1.title} - результаты:")
|
print(f" 🎉 {lottery1.title} - результаты:")
|
||||||
for place, winner_info in results1.items():
|
for place, winner_info in results1.items():
|
||||||
user = winner_info['user']
|
user = winner_info['user']
|
||||||
@@ -135,6 +136,7 @@ async def demo_admin_features():
|
|||||||
|
|
||||||
# Второй розыгрыш
|
# Второй розыгрыш
|
||||||
results2 = await LotteryService.conduct_draw(session, lottery2.id)
|
results2 = await LotteryService.conduct_draw(session, lottery2.id)
|
||||||
|
await session.commit()
|
||||||
print(f" 🚗 {lottery2.title} - результаты:")
|
print(f" 🚗 {lottery2.title} - результаты:")
|
||||||
for place, winner_info in results2.items():
|
for place, winner_info in results2.items():
|
||||||
user = winner_info['user']
|
user = winner_info['user']
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async def conduct_simple_draw():
|
|||||||
try:
|
try:
|
||||||
# Проводим розыгрыш
|
# Проводим розыгрыш
|
||||||
winners = await LotteryService.conduct_draw(session, lottery_id)
|
winners = await LotteryService.conduct_draw(session, lottery_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
print(f"🎉 Розыгрыш проведен!")
|
print(f"🎉 Розыгрыш проведен!")
|
||||||
print(f"📊 Результат: {winners}")
|
print(f"📊 Результат: {winners}")
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
|
|||||||
from ..utils.account_utils import mask_account_number
|
from ..utils.account_utils import mask_account_number
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_account_number(user: User) -> Optional[str]:
|
||||||
|
"""Вернуть номер счета из совместимого поля или уже загруженной связи."""
|
||||||
|
account_number = getattr(user, 'account_number', None)
|
||||||
|
if account_number:
|
||||||
|
return account_number
|
||||||
|
|
||||||
|
# Не инициируем lazy-load в async SQLAlchemy: берем только уже загруженную связь.
|
||||||
|
accounts = getattr(user, '__dict__', {}).get('accounts') or []
|
||||||
|
for account in accounts:
|
||||||
|
if getattr(account, 'is_active', True) and getattr(account, 'account_number', None):
|
||||||
|
return account.account_number
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
|
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
Форматирует отображение победителя в зависимости от настроек розыгрыша
|
Форматирует отображение победителя в зависимости от настроек розыгрыша
|
||||||
@@ -33,7 +48,7 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
|
|||||||
|
|
||||||
elif display_type == 'account_number':
|
elif display_type == 'account_number':
|
||||||
# Отображаем номер клиентского счета
|
# Отображаем номер клиентского счета
|
||||||
account_number = getattr(user, "account_number", None)
|
account_number = _get_user_account_number(user)
|
||||||
if not account_number:
|
if not account_number:
|
||||||
return "Счёт не указан"
|
return "Счёт не указан"
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Keep commands out of ordinary form input and messages being relayed."""
|
|
||||||
from aiogram.filters import Filter
|
|
||||||
from aiogram.types import Message
|
|
||||||
|
|
||||||
|
|
||||||
class NotCommand(Filter):
|
|
||||||
def __init__(self, *allowed):
|
|
||||||
self.allowed = {name.lower() for name in allowed}
|
|
||||||
|
|
||||||
async def __call__(self, message: Message):
|
|
||||||
text = (message.text or "").lstrip()
|
|
||||||
if not text.startswith("/"):
|
|
||||||
return True
|
|
||||||
command = text.split()[0].split("@", 1)[0][1:].lower()
|
|
||||||
# /skip is an intentional value in the channel description dialog.
|
|
||||||
return command in self.allowed and len(text.split()) == 1
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Match a whole callback ID so one menu cannot consume another menu's action."""
|
|
||||||
from aiogram.filters import Filter
|
|
||||||
from aiogram.types import CallbackQuery
|
|
||||||
|
|
||||||
|
|
||||||
class NumericCallback(Filter):
|
|
||||||
def __init__(self, prefix: str, *, suffixes=(), maximum=2**31 - 1):
|
|
||||||
self.prefix = prefix
|
|
||||||
self.suffixes = suffixes
|
|
||||||
self.maximum = maximum
|
|
||||||
|
|
||||||
async def __call__(self, callback: CallbackQuery):
|
|
||||||
if not callback.data or not callback.data.startswith(self.prefix):
|
|
||||||
return False
|
|
||||||
value = callback.data[len(self.prefix):]
|
|
||||||
if self.suffixes:
|
|
||||||
value, separator, suffix = value.partition("_")
|
|
||||||
if not separator or suffix not in self.suffixes:
|
|
||||||
return False
|
|
||||||
return bool(value and len(value) <= 19 and value.isascii() and value.isdigit()
|
|
||||||
and 0 < int(value) <= self.maximum)
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Обработчики для работы со счетами в розыгрышах
|
Обработчики для работы со счетами в розыгрышах
|
||||||
"""
|
"""
|
||||||
from src.filters.numeric_callback import NumericCallback
|
|
||||||
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
|
||||||
@@ -16,9 +14,6 @@ from ..core.services import LotteryService
|
|||||||
from .account_services import AccountParticipationService
|
from .account_services import AccountParticipationService
|
||||||
from ..utils.account_utils import parse_accounts_from_message, validate_account_number
|
from ..utils.account_utils import parse_accounts_from_message, validate_account_number
|
||||||
from typing import List
|
from typing import List
|
||||||
from html import escape
|
|
||||||
from ..utils.text_output import answer_plain_pages, edit_plain_pages
|
|
||||||
from ..utils.account_input import parse_account_records
|
|
||||||
|
|
||||||
|
|
||||||
# Состояния FSM для работы со счетами
|
# Состояния FSM для работы со счетами
|
||||||
@@ -33,6 +28,9 @@ 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(
|
||||||
@@ -46,19 +44,14 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
Активируется только для администраторов
|
Активируется только для администраторов
|
||||||
Извлекает номер клубной карты и определяет владельца
|
Извлекает номер клубной карты и определяет владельца
|
||||||
"""
|
"""
|
||||||
if not await is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
from aiogram.dispatcher.event.bases import SkipHandler
|
return
|
||||||
raise SkipHandler()
|
|
||||||
|
|
||||||
# Парсим счета из сообщения
|
# Парсим счета из сообщения
|
||||||
accounts, errors = parse_account_records(message.text)
|
accounts = parse_accounts_from_message(message.text)
|
||||||
|
|
||||||
if not accounts:
|
if not accounts:
|
||||||
from aiogram.dispatcher.event.bases import SkipHandler
|
return # Счета не обнаружены, пропускаем
|
||||||
raise SkipHandler()
|
|
||||||
if errors:
|
|
||||||
await answer_plain_pages(message, "Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Извлекаем номера клубных карт и определяем владельцев
|
# Извлекаем номера клубных карт и определяем владельцев
|
||||||
from ..core.services import UserService
|
from ..core.services import UserService
|
||||||
@@ -126,9 +119,9 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
|
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n"
|
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
|
||||||
f"Найдено: {count}\n"
|
f"Найдено: <b>{count}</b>\n"
|
||||||
f"Владельцев определено: {owners_found}\n\n"
|
f"Владельцев определено: <b>{owners_found}</b>\n\n"
|
||||||
f"{accounts_text}\n\n"
|
f"{accounts_text}\n\n"
|
||||||
f"Выберите действие:"
|
f"Выберите действие:"
|
||||||
)
|
)
|
||||||
@@ -149,8 +142,7 @@ async def detect_account_input(message: Message, state: FSMContext):
|
|||||||
)]
|
)]
|
||||||
])
|
])
|
||||||
|
|
||||||
# Owner names and long pasted lists must not break Telegram markup/limits.
|
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
await answer_plain_pages(message, text, reply_markup=keyboard)
|
|
||||||
|
|
||||||
|
|
||||||
@account_router.callback_query(F.data == "account_action:cancel")
|
@account_router.callback_query(F.data == "account_action:cancel")
|
||||||
@@ -164,12 +156,9 @@ 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 await is_admin(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
|
||||||
if not (await state.get_data()).get("detected_accounts"):
|
|
||||||
await callback.answer("Данные устарели. Отправьте счета заново.", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Получаем активные розыгрыши
|
# Получаем активные розыгрыши
|
||||||
@@ -208,10 +197,10 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@account_router.callback_query(NumericCallback('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 await is_admin(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
|
||||||
|
|
||||||
@@ -240,40 +229,36 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
# A skipped account rolls back the session and expires ORM objects.
|
|
||||||
lottery_title = lottery.title
|
|
||||||
# Добавляем счета
|
# Добавляем счета
|
||||||
results = await AccountParticipationService.add_accounts_bulk(
|
results = await AccountParticipationService.add_accounts_bulk(
|
||||||
session, lottery_id, accounts
|
session, lottery_id, accounts
|
||||||
)
|
)
|
||||||
|
|
||||||
# Формируем результат
|
# Формируем результат
|
||||||
await state.clear()
|
text = f"<b>Результаты добавления в розыгрыш:</b>\n<i>{lottery.title}</i>\n\n"
|
||||||
text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n"
|
text += f"✅ Добавлено: <b>{results['added']}</b>\n"
|
||||||
text += f"✅ Добавлено: {results['added']}\n"
|
text += f"⚠️ Пропущено: <b>{results['skipped']}</b>\n\n"
|
||||||
if results['linked']:
|
|
||||||
text += f"✅ Привязано существующих участий: {results['linked']}\n"
|
|
||||||
text += f"⚠️ Пропущено: {results['skipped']}\n\n"
|
|
||||||
|
|
||||||
if results['details']:
|
if results['details']:
|
||||||
text += "Детали:\n"
|
text += "<b>Детали:</b>\n"
|
||||||
text += "\n".join(results['details'][:20]) # Показываем первые 20
|
text += "\n".join(results['details'][:20]) # Показываем первые 20
|
||||||
|
|
||||||
if len(results['details']) > 20:
|
if len(results['details']) > 20:
|
||||||
text += f"\n... и ещё {len(results['details']) - 20}"
|
text += f"\n... и ещё {len(results['details']) - 20}"
|
||||||
|
|
||||||
if results['errors']:
|
if results['errors']:
|
||||||
text += f"\n\nОшибки:\n"
|
text += f"\n\n<b>Ошибки:</b>\n"
|
||||||
text += "\n".join(results['errors'][:10])
|
text += "\n".join(results['errors'][:10])
|
||||||
|
|
||||||
await edit_plain_pages(callback.message, text)
|
await callback.message.edit_text(text, parse_mode="HTML")
|
||||||
|
await state.clear()
|
||||||
await callback.answer("✅ Готово!")
|
await callback.answer("✅ Готово!")
|
||||||
|
|
||||||
|
|
||||||
@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 await is_admin(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
|
||||||
|
|
||||||
@@ -291,7 +276,8 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
lotteries = await LotteryService.get_active_lotteries(session, limit=30)
|
# Получаем все розыгрыши (активные и завершенные)
|
||||||
|
lotteries = await LotteryService.get_all_lotteries(session, limit=30)
|
||||||
|
|
||||||
if not lotteries:
|
if not lotteries:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -330,18 +316,14 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@account_router.callback_query(NumericCallback('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 await is_admin(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
|
||||||
|
|
||||||
lottery_id = int(callback.data.split(":")[1])
|
lottery_id = int(callback.data.split(":")[1])
|
||||||
accounts = (await state.get_data()).get("detected_accounts", [])
|
|
||||||
if len(accounts) != 1:
|
|
||||||
await callback.answer("Данные устарели. Отправьте один счёт заново.", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Сохраняем ID розыгрыша
|
# Сохраняем ID розыгрыша
|
||||||
await state.update_data(winner_lottery_id=lottery_id)
|
await state.update_data(winner_lottery_id=lottery_id)
|
||||||
@@ -387,7 +369,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"👑 <b>Установка победителя</b>\n\n"
|
f"👑 <b>Установка победителя</b>\n\n"
|
||||||
f"Розыгрыш: <i>{escape(lottery.title)}</i>\n"
|
f"Розыгрыш: <i>{lottery.title}</i>\n"
|
||||||
f"Счет: <code>{account}</code>\n\n"
|
f"Счет: <code>{account}</code>\n\n"
|
||||||
f"Выберите место:",
|
f"Выберите место:",
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
@@ -397,10 +379,10 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@account_router.callback_query(F.data.regexp(r"^winner_place:[0-9]{1,3}$"))
|
@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 await is_admin(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
|
||||||
|
|
||||||
@@ -408,8 +390,7 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
# Получаем данные
|
# Получаем данные
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
accounts = data.get("detected_accounts", [])
|
account = data.get("detected_accounts", [])[0]
|
||||||
account = accounts[0] if len(accounts) == 1 else None
|
|
||||||
lottery_id = data.get("winner_lottery_id")
|
lottery_id = data.get("winner_lottery_id")
|
||||||
|
|
||||||
if not account or not lottery_id:
|
if not account or not lottery_id:
|
||||||
@@ -423,14 +404,6 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
if not lottery:
|
|
||||||
await state.clear()
|
|
||||||
await callback.answer("Розыгрыш не найден", show_alert=True)
|
|
||||||
return
|
|
||||||
if not 1 <= place <= len(lottery.prizes or [None]):
|
|
||||||
await callback.answer("Некорректное призовое место", show_alert=True)
|
|
||||||
return
|
|
||||||
lottery_title = lottery.title
|
|
||||||
|
|
||||||
# Получаем приз для этого места
|
# Получаем приз для этого места
|
||||||
prize = None
|
prize = None
|
||||||
@@ -443,20 +416,20 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
|||||||
session, lottery_id, account, place, prize
|
session, lottery_id, account, place, prize
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
text = (
|
text = (
|
||||||
f"✅ <b>Победитель установлен!</b>\n\n"
|
f"✅ <b>Победитель установлен!</b>\n\n"
|
||||||
f"Розыгрыш: <i>{escape(lottery_title)}</i>\n"
|
f"Розыгрыш: <i>{lottery.title}</i>\n"
|
||||||
f"Счет: <code>{account}</code>\n"
|
f"Счет: <code>{account}</code>\n"
|
||||||
f"Место: <b>{place}</b>\n"
|
f"Место: <b>{place}</b>\n"
|
||||||
)
|
)
|
||||||
if prize:
|
if prize:
|
||||||
text += f"Приз: <i>{escape(prize)}</i>"
|
text += f"Приз: <i>{prize}</i>"
|
||||||
|
|
||||||
await callback.answer("✅ Победитель установлен!", show_alert=True)
|
await callback.answer("✅ Победитель установлен!", show_alert=True)
|
||||||
else:
|
else:
|
||||||
text = f"❌ {escape(result['message'])}"
|
text = f"❌ {result['message']}"
|
||||||
await callback.answer("❌ Ошибка", show_alert=True)
|
await callback.answer("❌ Ошибка", show_alert=True)
|
||||||
|
|
||||||
await callback.message.edit_text(text, parse_mode="HTML")
|
await callback.message.edit_text(text, parse_mode="HTML")
|
||||||
|
await state.clear()
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Сервис участия счетов: привязка к клиентам и проверки открытого розыгрыша.
|
Сервис для работы с участием счетов в розыгрышах (без привязки к пользователям)
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete, func
|
from sqlalchemy import select, delete, func
|
||||||
from ..core.models import Account, Lottery, Participation, User, 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
|
|
||||||
from ..utils.account_input import card_account_entry
|
|
||||||
|
|
||||||
|
|
||||||
class AccountParticipationService:
|
class AccountParticipationService:
|
||||||
@@ -18,21 +16,15 @@ class AccountParticipationService:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_number: str,
|
account_number: str,
|
||||||
*, require_card: bool = False
|
commit: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Добавить счет в розыгрыш.
|
Добавить счет в розыгрыш.
|
||||||
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
В кассе require_card=True запрещает участие без явно указанной карты.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict с ключами: success, message, account_number
|
Dict с ключами: success, message, account_number
|
||||||
"""
|
"""
|
||||||
if require_card:
|
|
||||||
try:
|
|
||||||
account_number = card_account_entry(account_number)
|
|
||||||
except ValueError as error:
|
|
||||||
return {"success": False, "message": str(error), "account_number": account_number}
|
|
||||||
# Разделяем по пробелу если есть номер карты
|
# Разделяем по пробелу если есть номер карты
|
||||||
parts = account_number.split()
|
parts = account_number.split()
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
@@ -58,9 +50,6 @@ 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:
|
||||||
@@ -77,9 +66,7 @@ class AccountParticipationService:
|
|||||||
Participation.account_number == formatted_account
|
Participation.account_number == formatted_account
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = existing.scalar_one_or_none()
|
if existing.scalar_one_or_none():
|
||||||
if existing and not card_number:
|
|
||||||
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,
|
||||||
@@ -87,43 +74,15 @@ class AccountParticipationService:
|
|||||||
"account_number": formatted_account
|
"account_number": formatted_account
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register a new ticket for the supplied card in the same transaction.
|
# Получаем запись Account и владельца
|
||||||
# Conflict handling also protects two cashiers adding it to different draws.
|
from ..core.registration_services import AccountService
|
||||||
account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account))
|
from ..core.models import Account
|
||||||
if card_number and not account_record:
|
|
||||||
owner_id = await session.scalar(select(User.id).where(User.club_card_number == card_number))
|
|
||||||
if owner_id is None:
|
|
||||||
await session.rollback()
|
|
||||||
return {"success": False, "message": "Владелец клубной карты не найден. Сначала зарегистрируйте клиента.",
|
|
||||||
"account_number": formatted_account}
|
|
||||||
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
|
|
||||||
await session.execute(insert(Account).values(account_number=formatted_account, owner_id=owner_id, is_active=True)
|
|
||||||
.on_conflict_do_nothing(index_elements=[Account.account_number]))
|
|
||||||
account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account))
|
|
||||||
user = await session.get(User, account_record.owner_id) if account_record else 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}
|
|
||||||
|
|
||||||
if existing:
|
user = await AccountService.get_account_owner(session, formatted_account)
|
||||||
if user and (existing.user_id not in (None, user.id) or existing.account_id not in (None, account_record.id)):
|
account_record = await session.execute(
|
||||||
await session.rollback()
|
select(Account).where(Account.account_number == formatted_account)
|
||||||
return {"success": False, "message": "Участие связано с другим владельцем. Обратитесь к администратору.",
|
)
|
||||||
"account_number": formatted_account}
|
account_record = account_record.scalar_one_or_none()
|
||||||
if user and (existing.user_id is None or existing.account_id is None):
|
|
||||||
existing.user_id = user.id
|
|
||||||
existing.account_id = account_record.id
|
|
||||||
await session.commit()
|
|
||||||
return {"success": True, "linked": True, "account_number": formatted_account,
|
|
||||||
"message": f"Счет {formatted_account} привязан к карте {card_number}; новое участие не создавалось"}
|
|
||||||
await session.rollback()
|
|
||||||
return {"success": False, "message": f"Счет {formatted_account} уже участвует в розыгрыше",
|
|
||||||
"account_number": formatted_account}
|
|
||||||
|
|
||||||
# Добавляем участие с полными данными
|
# Добавляем участие с полными данными
|
||||||
participation = Participation(
|
participation = Participation(
|
||||||
@@ -133,7 +92,10 @@ class AccountParticipationService:
|
|||||||
account_id=account_record.id if account_record else None
|
account_id=account_record.id if account_record else None
|
||||||
)
|
)
|
||||||
session.add(participation)
|
session.add(participation)
|
||||||
|
if commit:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
else:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
@@ -146,15 +108,13 @@ class AccountParticipationService:
|
|||||||
async def add_accounts_bulk(
|
async def add_accounts_bulk(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_numbers: List[str],
|
account_numbers: List[str]
|
||||||
*, require_card: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Массовое добавление счетов в розыгрыш
|
Массовое добавление счетов в розыгрыш
|
||||||
"""
|
"""
|
||||||
results = {
|
results = {
|
||||||
"added": 0,
|
"added": 0,
|
||||||
"linked": 0,
|
|
||||||
"skipped": 0,
|
"skipped": 0,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"details": [],
|
"details": [],
|
||||||
@@ -162,24 +122,12 @@ class AccountParticipationService:
|
|||||||
"skipped_accounts": []
|
"skipped_accounts": []
|
||||||
}
|
}
|
||||||
|
|
||||||
if require_card:
|
|
||||||
try:
|
|
||||||
account_numbers = [card_account_entry(account) for account in account_numbers]
|
|
||||||
except ValueError as error:
|
|
||||||
results["skipped"] = len(account_numbers)
|
|
||||||
results["skipped_accounts"] = list(account_numbers)
|
|
||||||
results["errors"].append(str(error))
|
|
||||||
return results
|
|
||||||
|
|
||||||
for account in account_numbers:
|
for account in account_numbers:
|
||||||
result = await AccountParticipationService.add_account_to_lottery(
|
result = await AccountParticipationService.add_account_to_lottery(
|
||||||
session, lottery_id, account, require_card=require_card
|
session, lottery_id, account, commit=False
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get("linked"):
|
if result["success"]:
|
||||||
results["linked"] += 1
|
|
||||||
results["details"].append(f"✅ {result['message']}")
|
|
||||||
elif result["success"]:
|
|
||||||
results["added"] += 1
|
results["added"] += 1
|
||||||
results["added_accounts"].append(result["account_number"])
|
results["added_accounts"].append(result["account_number"])
|
||||||
results["details"].append(f"✅ {result['account_number']}")
|
results["details"].append(f"✅ {result['account_number']}")
|
||||||
@@ -189,13 +137,16 @@ class AccountParticipationService:
|
|||||||
results["errors"].append(result["message"])
|
results["errors"].append(result["message"])
|
||||||
results["details"].append(f"❌ {result['message']}")
|
results["details"].append(f"❌ {result['message']}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def remove_account_from_lottery(
|
async def remove_account_from_lottery(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_number: str
|
account_number: str,
|
||||||
|
commit: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Удалить счет из розыгрыша"""
|
"""Удалить счет из розыгрыша"""
|
||||||
formatted_account = format_account_number(account_number)
|
formatted_account = format_account_number(account_number)
|
||||||
@@ -205,9 +156,6 @@ 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,
|
||||||
@@ -217,14 +165,16 @@ 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} не участвует в розыгрыше"
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.delete(participation)
|
await session.delete(participation)
|
||||||
|
if commit:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
else:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -276,13 +226,21 @@ class AccountParticipationService:
|
|||||||
pattern: Паттерн поиска (например "11-22" или "33")
|
pattern: Паттерн поиска (например "11-22" или "33")
|
||||||
limit: Максимальное количество результатов
|
limit: Максимальное количество результатов
|
||||||
"""
|
"""
|
||||||
# Получаем все счета розыгрыша
|
digits_pattern = ''.join(c for c in pattern if c.isdigit())
|
||||||
all_accounts = await AccountParticipationService.get_lottery_accounts(
|
if not digits_pattern:
|
||||||
session, lottery_id
|
return []
|
||||||
)
|
|
||||||
|
|
||||||
# Ищем совпадения
|
result = await session.execute(
|
||||||
return search_accounts_by_pattern(pattern, all_accounts)[:limit]
|
select(Participation.account_number)
|
||||||
|
.where(
|
||||||
|
Participation.lottery_id == lottery_id,
|
||||||
|
Participation.account_number.isnot(None),
|
||||||
|
func.replace(Participation.account_number, '-', '').contains(digits_pattern),
|
||||||
|
)
|
||||||
|
.order_by(Participation.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [account for account in result.scalars().all() if account]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def set_account_as_winner(
|
async def set_account_as_winner(
|
||||||
@@ -296,13 +254,6 @@ 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()
|
||||||
@@ -343,17 +294,6 @@ class AccountParticipationService:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше"
|
"message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше"
|
||||||
}
|
}
|
||||||
if card_number:
|
|
||||||
from ..core.registration_services import AccountService
|
|
||||||
owner = await AccountService.get_account_owner(session, formatted_account)
|
|
||||||
if not owner or owner.club_card_number != card_number:
|
|
||||||
await session.rollback()
|
|
||||||
return {"success": False, "message": "Карта не соответствует владельцу счета"}
|
|
||||||
if await session.scalar(select(Winner.id).where(
|
|
||||||
Winner.lottery_id == lottery_id, Winner.account_number == formatted_account, Winner.place != place
|
|
||||||
).limit(1)):
|
|
||||||
await session.rollback()
|
|
||||||
return {"success": False, "message": "Счет уже назначен на другое призовое место"}
|
|
||||||
|
|
||||||
# Проверяем, не занято ли уже это место
|
# Проверяем, не занято ли уже это место
|
||||||
existing_winner = await session.execute(
|
existing_winner = await session.execute(
|
||||||
@@ -367,7 +307,7 @@ class AccountParticipationService:
|
|||||||
if existing_winner:
|
if existing_winner:
|
||||||
# Обновляем существующего победителя
|
# Обновляем существующего победителя
|
||||||
existing_winner.account_number = formatted_account
|
existing_winner.account_number = formatted_account
|
||||||
existing_winner.user_id = participation.user_id
|
existing_winner.user_id = None
|
||||||
existing_winner.is_manual = True
|
existing_winner.is_manual = True
|
||||||
if prize:
|
if prize:
|
||||||
existing_winner.prize = prize
|
existing_winner.prize = prize
|
||||||
@@ -376,7 +316,7 @@ class AccountParticipationService:
|
|||||||
winner = Winner(
|
winner = Winner(
|
||||||
lottery_id=lottery_id,
|
lottery_id=lottery_id,
|
||||||
account_number=formatted_account,
|
account_number=formatted_account,
|
||||||
user_id=participation.user_id,
|
user_id=None,
|
||||||
place=place,
|
place=place,
|
||||||
prize=prize,
|
prize=prize,
|
||||||
is_manual=True
|
is_manual=True
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"""Админские обработчики для управления счетами и верификации"""
|
"""Админские обработчики для управления счетами и верификации"""
|
||||||
from src.filters.numeric_callback import NumericCallback
|
|
||||||
from src.utils.account_input import parse_account_records
|
|
||||||
from src.utils.text_output import answer_plain_pages, edit_plain_pages
|
|
||||||
from src.utils.input_validation import numeric_id
|
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
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 src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
from aiogram.filters import Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
@@ -18,7 +12,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.access import staff_only
|
from src.core.permissions import admin_only
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -30,7 +24,7 @@ class AddAccountStates(StatesGroup):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("cancel"))
|
@router.message(CaseInsensitiveCommand("cancel"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def cancel_command(message: Message, state: FSMContext):
|
async def cancel_command(message: Message, state: FSMContext):
|
||||||
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -38,7 +32,7 @@ async def cancel_command(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("add_account"))
|
@router.message(CaseInsensitiveCommand("add_account"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def add_account_command(message: Message, state: FSMContext):
|
async def add_account_command(message: Message, state: FSMContext):
|
||||||
"""
|
"""
|
||||||
Добавить счет пользователю по клубной карте (регистронезависимо)
|
Добавить счет пользователю по клубной карте (регистронезависимо)
|
||||||
@@ -113,7 +107,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"⚠️ Не удалось отправить уведомление: {public_error(e)}\n\n"
|
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
|
||||||
|
|
||||||
# Предлагаем добавить в розыгрыш
|
# Предлагаем добавить в розыгрыш
|
||||||
await show_lottery_selection(message, text, state)
|
await show_lottery_selection(message, text, state)
|
||||||
@@ -122,11 +116,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"❌ Произошла ошибка: {public_error(e)}")
|
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AddAccountStates.waiting_for_data, F.text, NotCommand())
|
@router.message(AddAccountStates.waiting_for_data)
|
||||||
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':
|
||||||
@@ -145,6 +139,7 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
f"Вы отправили: {len(lines)} строк\n\n"
|
f"Вы отправили: {len(lines)} строк\n\n"
|
||||||
f"Разделите данные на несколько частей."
|
f"Разделите данные на несколько частей."
|
||||||
)
|
)
|
||||||
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Отправляем начальное уведомление
|
# Отправляем начальное уведомление
|
||||||
@@ -154,17 +149,46 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
accounts_data = []
|
accounts_data = []
|
||||||
entries, errors = parse_account_records(message.text)
|
errors = []
|
||||||
|
|
||||||
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
|
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
|
||||||
|
|
||||||
# Универсальный парсер: поддержка однострочного и многострочного формата
|
# Универсальный парсер: поддержка однострочного и многострочного формата
|
||||||
for entry in entries:
|
i = 0
|
||||||
parts = entry.split()
|
while i < len(lines):
|
||||||
if len(parts) != 2:
|
line = lines[i].strip()
|
||||||
errors.append("Для счёта не указана клубная карта: " + entry)
|
|
||||||
|
# Пропускаем пустые строки и строки с названиями/датами
|
||||||
|
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
|
||||||
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
|
||||||
|
if ' ' in line:
|
||||||
|
# Однострочный формат: разделяем по первому пробелу
|
||||||
|
parts = line.split(maxsplit=1)
|
||||||
|
if len(parts) == 2:
|
||||||
club_card, account_number = parts
|
club_card, account_number = parts
|
||||||
|
else:
|
||||||
|
errors.append(f"Строка {i+1}: неверный формат")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Многострочный формат: текущая строка - счет, следующая - карта
|
||||||
|
account_number = line
|
||||||
|
i += 1
|
||||||
|
if i >= len(lines):
|
||||||
|
errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
|
||||||
|
break
|
||||||
|
|
||||||
|
club_card = lines[i].strip()
|
||||||
|
# Пропускаем, если следующая строка содержит мусор
|
||||||
|
if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
|
||||||
|
errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Создаем счет
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
account = await AccountService.create_account(
|
account = await AccountService.create_account(
|
||||||
@@ -196,8 +220,9 @@ 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}: {public_error(e)}")
|
errors.append(f"Счет {account_number}: {str(e)}")
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
# Удаляем progress сообщение
|
# Удаляем progress сообщение
|
||||||
try:
|
try:
|
||||||
@@ -291,11 +316,12 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
text += f"\n... и еще {len(errors) - 5} ошибок\n"
|
text += f"\n... и еще {len(errors) - 5} ошибок\n"
|
||||||
|
|
||||||
if not accounts_data:
|
if not accounts_data:
|
||||||
await answer_plain_pages(message, text)
|
await message.answer(text)
|
||||||
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сохраняем данные и предлагаем добавить в розыгрыш
|
# Сохраняем данные и предлагаем добавить в розыгрыш
|
||||||
await state.update_data(accounts=[{key: value for key, value in account.items() if key != "owner"} for account in accounts_data])
|
await state.update_data(accounts=accounts_data)
|
||||||
await show_lottery_selection(message, text, state)
|
await show_lottery_selection(message, text, state)
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +331,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
|
|||||||
lotteries = await LotteryService.get_active_lotteries(session)
|
lotteries = await LotteryService.get_active_lotteries(session)
|
||||||
|
|
||||||
if not lotteries:
|
if not lotteries:
|
||||||
await answer_plain_pages(message,
|
await message.answer(
|
||||||
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
|
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -326,14 +352,14 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
|
|||||||
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
|
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
|
||||||
])
|
])
|
||||||
|
|
||||||
await answer_plain_pages(message,
|
await message.answer(
|
||||||
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
||||||
"Выберите розыгрыш из списка:",
|
"Выберите розыгрыш из списка:",
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(NumericCallback('add_to_lottery_'))
|
@router.callback_query(F.data.startswith("add_to_lottery_"))
|
||||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Добавить счета в выбранный розыгрыш"""
|
"""Добавить счета в выбранный розыгрыш"""
|
||||||
lottery_id = int(callback.data.split("_")[-1])
|
lottery_id = int(callback.data.split("_")[-1])
|
||||||
@@ -357,28 +383,49 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
lottery_title = lottery.title
|
for acc in accounts:
|
||||||
from .account_services import AccountParticipationService
|
try:
|
||||||
entries = [f"{a.get('club_card') or ''} {a['account_number']}".strip() for a in accounts]
|
# Добавляем участие через account_id
|
||||||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, entries, require_card=True)
|
# Проверяем, не участвует ли уже
|
||||||
success_count = result['added']
|
existing = await session.execute(
|
||||||
errors = result['errors']
|
select(Participation).where(
|
||||||
|
and_(
|
||||||
|
Participation.lottery_id == lottery_id,
|
||||||
|
Participation.account_id == acc['account_id']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
if existing.scalar_one_or_none():
|
||||||
text = f"📊 Добавление в розыгрыш '{lottery_title}'\n\n"
|
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"
|
||||||
|
|
||||||
if success_count:
|
if success_count:
|
||||||
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
||||||
if result['linked']:
|
|
||||||
text += f"✅ Привязано существующих участий: {result['linked']}\n\n"
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
text += f"⚠️ Ошибки: {len(errors)}\n"
|
text += f"⚠️ Ошибки: {len(errors)}\n"
|
||||||
for error in errors[:3]:
|
for error in errors[:3]:
|
||||||
text += f"• {error}\n"
|
text += f"• {error}\n"
|
||||||
|
|
||||||
await edit_plain_pages(callback.message, text)
|
await callback.message.edit_text(text)
|
||||||
await callback.answer("Готово")
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "skip_lottery_add")
|
@router.callback_query(F.data == "skip_lottery_add")
|
||||||
@@ -389,7 +436,7 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("remove_account"))
|
@router.message(CaseInsensitiveCommand("remove_account"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def remove_account_command(message: Message):
|
async def remove_account_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Деактивировать счет(а) (регистронезависимо)
|
Деактивировать счет(а) (регистронезависимо)
|
||||||
@@ -455,11 +502,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"❌ Критическая ошибка: {public_error(e)}")
|
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("verify_winner"))
|
@router.message(CaseInsensitiveCommand("verify_winner"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def verify_winner_command(message: Message):
|
async def verify_winner_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
||||||
@@ -481,7 +528,7 @@ async def verify_winner_command(message: Message):
|
|||||||
verification_code = parts[1].upper()
|
verification_code = parts[1].upper()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = numeric_id(parts[2], maximum=2**31 - 1)
|
lottery_id = int(parts[2])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
@@ -538,7 +585,7 @@ async def verify_winner_command(message: Message):
|
|||||||
)
|
)
|
||||||
text += "\n📨 Победителю отправлено уведомление"
|
text += "\n📨 Победителю отправлено уведомление"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
text += f"\n⚠️ Не удалось отправить уведомление: {public_error(e)}"
|
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
|
||||||
|
|
||||||
if winner.account_number:
|
if winner.account_number:
|
||||||
text += f"💳 Счет: {winner.account_number}\n"
|
text += f"💳 Счет: {winner.account_number}\n"
|
||||||
@@ -546,11 +593,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"❌ Ошибка: {public_error(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("winner_status"))
|
@router.message(CaseInsensitiveCommand("winner_status"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def winner_status_command(message: Message):
|
async def winner_status_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать статус всех победителей розыгрыша (регистронезависимо)
|
Показать статус всех победителей розыгрыша (регистронезависимо)
|
||||||
@@ -566,7 +613,7 @@ async def winner_status_command(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
lottery_id = int(parts[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
@@ -619,11 +666,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"❌ Ошибка: {public_error(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("user_info"))
|
@router.message(CaseInsensitiveCommand("user_info"))
|
||||||
@staff_only
|
@admin_only
|
||||||
async def user_info_command(message: Message):
|
async def user_info_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать информацию о пользователе (регистронезависимо)
|
Показать информацию о пользователе (регистронезависимо)
|
||||||
@@ -694,7 +741,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"❌ Ошибка: {public_error(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "view_my_accounts")
|
@router.callback_query(F.data == "view_my_accounts")
|
||||||
@@ -760,6 +807,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"❌ Ошибка: {public_error(e)}")
|
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
except:
|
except:
|
||||||
pass # Игнорируем если не получилось отправить
|
pass # Игнорируем если не получилось отправить
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""Админские обработчики для управления чатом"""
|
"""Админские обработчики для управления чатом"""
|
||||||
from html import escape
|
|
||||||
from src.utils.input_validation import numeric_id
|
|
||||||
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 src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
from aiogram.filters import Command
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
@@ -22,23 +19,6 @@ from src.core.permissions import admin_only
|
|||||||
router = Router(name='admin_chat_router')
|
router = Router(name='admin_chat_router')
|
||||||
|
|
||||||
|
|
||||||
async def reply_target(message: Message):
|
|
||||||
"""Resolve the human author of a bot copy only within the operator's dialog."""
|
|
||||||
replied = message.reply_to_message
|
|
||||||
if replied.from_user and not replied.from_user.is_bot:
|
|
||||||
return replied.from_user.id
|
|
||||||
from sqlalchemy import select
|
|
||||||
from src.core.models import User, P2PMessage
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
original = await ChatMessageService.get_message_by_telegram_id(
|
|
||||||
session, replied.message_id, chat_id=message.chat.id)
|
|
||||||
if original:
|
|
||||||
return await session.scalar(select(User.telegram_id).where(User.id == original.user_id))
|
|
||||||
operator_id = select(User.id).where(User.telegram_id == message.from_user.id).scalar_subquery()
|
|
||||||
return await session.scalar(select(User.telegram_id).join(P2PMessage, P2PMessage.sender_id == User.id).where(
|
|
||||||
P2PMessage.recipient_id == operator_id, P2PMessage.recipient_message_id == replied.message_id))
|
|
||||||
|
|
||||||
|
|
||||||
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
||||||
"""Клавиатура выбора режима чата"""
|
"""Клавиатура выбора режима чата"""
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
@@ -108,11 +88,7 @@ async def cmd_set_forward(message: Message):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
chat_id = args[1].strip()
|
||||||
chat_id = str(numeric_id(args[1], signed=True))
|
|
||||||
except ValueError as error:
|
|
||||||
await message.answer(str(error))
|
|
||||||
return
|
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
|
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
|
||||||
@@ -158,10 +134,7 @@ async def cmd_ban(message: Message):
|
|||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
target_user_id = await reply_target(message)
|
target_user_id = message.reply_to_message.from_user.id
|
||||||
if target_user_id is None:
|
|
||||||
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
|
|
||||||
return
|
|
||||||
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
|
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
|
||||||
else:
|
else:
|
||||||
args = message.text.split(maxsplit=2)
|
args = message.text.split(maxsplit=2)
|
||||||
@@ -176,7 +149,7 @@ async def cmd_ban(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_user_id = numeric_id(args[1])
|
target_user_id = int(args[1])
|
||||||
reason = args[2] if len(args) > 2 else None
|
reason = args[2] if len(args) > 2 else None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ Неверный ID пользователя")
|
await message.answer("❌ Неверный ID пользователя")
|
||||||
@@ -208,11 +181,11 @@ async def cmd_ban(message: Message):
|
|||||||
reason=reason
|
reason=reason
|
||||||
)
|
)
|
||||||
|
|
||||||
reason_text = f"\n📝 Причина: {escape(reason[:500])}" if reason else ""
|
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"🚫 <b>Пользователь забанен</b>\n\n"
|
f"🚫 <b>Пользователь забанен</b>\n\n"
|
||||||
f"👤 Пользователь: {escape(user.nickname or user.first_name or 'Неизвестен')}\n"
|
f"👤 Пользователь: {user.name or 'Неизвестен'}\n"
|
||||||
f"🆔 ID: <code>{target_user_id}</code>"
|
f"🆔 ID: <code>{target_user_id}</code>"
|
||||||
f"{reason_text}",
|
f"{reason_text}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
@@ -226,10 +199,7 @@ async def cmd_unban(message: Message):
|
|||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
target_user_id = await reply_target(message)
|
target_user_id = message.reply_to_message.from_user.id
|
||||||
if target_user_id is None:
|
|
||||||
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
|
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
args = message.text.split()
|
args = message.text.split()
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
@@ -243,7 +213,7 @@ async def cmd_unban(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_user_id = numeric_id(args[1])
|
target_user_id = int(args[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ Неверный ID пользователя")
|
await message.answer("❌ Неверный ID пользователя")
|
||||||
return
|
return
|
||||||
@@ -281,11 +251,11 @@ async def cmd_banlist(message: Message):
|
|||||||
user = ban.user
|
user = ban.user
|
||||||
admin = ban.admin
|
admin = ban.admin
|
||||||
|
|
||||||
text += f"👤 {escape(user.nickname or user.first_name or 'Неизвестен')} (<code>{ban.telegram_id}</code>)\n"
|
text += f"👤 {user.name or 'Неизвестен'} (<code>{ban.telegram_id}</code>)\n"
|
||||||
text += f"🔨 Забанил: {escape((admin.nickname or admin.first_name or 'Неизвестен') if admin else 'Неизвестен')}\n"
|
text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n"
|
||||||
|
|
||||||
if ban.reason:
|
if ban.reason:
|
||||||
text += f"📝 Причина: {escape(ban.reason[:500])}\n"
|
text += f"📝 Причина: {ban.reason}\n"
|
||||||
|
|
||||||
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
|
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
@@ -320,9 +290,12 @@ 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
|
||||||
|
|
||||||
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
result = await session.execute(
|
||||||
session, message.reply_to_message.message_id, chat_id=message.chat.id,
|
select(ChatMessage).where(
|
||||||
|
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("❌ Сообщение не найдено в базе данных")
|
||||||
@@ -344,7 +317,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}: {public_error(e)}")
|
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}")
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ <b>Сообщение удалено</b>\n\n"
|
f"✅ <b>Сообщение удалено</b>\n\n"
|
||||||
|
|||||||
@@ -2,20 +2,15 @@
|
|||||||
Хендлеры для управления кастомными эмодзи админом
|
Хендлеры для управления кастомными эмодзи админом
|
||||||
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||||
"""
|
"""
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
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 StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..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
|
||||||
|
|
||||||
@@ -31,36 +26,73 @@ 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 not await is_admin(message.from_user.id):
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"🎨 Отправьте один премиум-эмодзи из панели Telegram.\n\n"
|
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\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, F.text, NotCommand())
|
@router.message(EmojiStates.waiting_for_emoji)
|
||||||
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 len(entities) != 1:
|
if not message.text or len(message.text) > 10:
|
||||||
await message.answer("Отправьте один премиум-эмодзи из панели эмодзи Telegram. Обычный символ не содержит его ID.")
|
await message.answer(
|
||||||
|
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
entity = entities[0]
|
|
||||||
emoji_text = entity.extract_from(message.text)
|
emoji_text = message.text.strip()
|
||||||
|
|
||||||
|
# Проверяем что хотя бы один символ это эмодзи
|
||||||
|
has_emoji = any(ord(c) > 127 for c in emoji_text)
|
||||||
|
if not has_emoji:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Извлекаем emoji_id из entities если это есть
|
||||||
|
emoji_id = None
|
||||||
|
|
||||||
|
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
|
||||||
|
if message.entities:
|
||||||
|
for entity in message.entities:
|
||||||
|
if entity.type == "custom_emoji":
|
||||||
|
# Получаем text с этим entity
|
||||||
emoji_id = entity.custom_emoji_id
|
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():
|
break
|
||||||
await message.answer("Отправьте только один премиум-эмодзи, без дополнительного текста.")
|
|
||||||
return
|
# Если нет custom_emoji entity, пробуем другой способ
|
||||||
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id)
|
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>{escape(emoji_text or '')}</code>\n\n"
|
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
|
||||||
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
||||||
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
@@ -68,7 +100,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, F.text, NotCommand())
|
@router.message(EmojiStates.waiting_for_description)
|
||||||
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:
|
||||||
@@ -76,18 +108,10 @@ 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)
|
||||||
@@ -96,8 +120,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"⚠️ Вы уже зарегистрировали этот эмодзи: {escape(emoji_text or '')}\n"
|
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
|
||||||
f"Описание: <code>{escape(existing.description or '')}</code>",
|
f"Описание: <code>{existing.description}</code>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -113,18 +137,16 @@ 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>{escape(emoji_text or '')}</code>\n"
|
f"Эмодзи: <code>{emoji_text}</code>\n"
|
||||||
f"Описание: <code>{escape(description or '')}</code>\n"
|
f"Описание: <code>{description}</code>\n"
|
||||||
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
||||||
"Эмодзи сохранён в каталоге. В чатах и рассылках отправляйте его из панели Telegram.",
|
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
|
||||||
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"❌ Ошибка при сохранении эмодзи: {public_error(e)}",
|
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +156,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 not await is_admin(message.from_user.id):
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -152,7 +174,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>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
f"<code>{emoji.emoji_text}</code> — {emoji.description}\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"
|
||||||
)
|
)
|
||||||
@@ -163,7 +185,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 not await is_admin(message.from_user.id):
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -180,8 +202,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>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||||
f" Админ: <code>{escape(emoji.admin.first_name or 'Администратор') if emoji.admin else 'Администратор'}</code> "
|
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</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"
|
||||||
)
|
)
|
||||||
@@ -192,7 +214,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 not await is_admin(message.from_user.id):
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
await message.answer("❌ Эта команда доступна только администраторам")
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -211,7 +233,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 or ''})",
|
text=f"{emoji.emoji_text} ({emoji.description})",
|
||||||
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
@@ -236,14 +258,14 @@ async def delete_emoji_confirm(callback: CallbackQuery):
|
|||||||
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (not emoji.admin or emoji.admin.telegram_id != callback.from_user.id) and callback.from_user.id not in ADMIN_IDS:
|
if emoji.admin_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"✅ Эмодзи {emoji.emoji_text} удалено",
|
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
await callback.message.delete()
|
await callback.message.delete()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
|||||||
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
|
||||||
from src.filters.numeric_callback import NumericCallback
|
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
from aiogram import F, Router
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
|
||||||
|
|
||||||
from src.core.access import staff_only
|
|
||||||
from src.core.database import async_session_maker
|
|
||||||
from src.core.services import LotteryService
|
|
||||||
from src.handlers.account_services import AccountParticipationService
|
|
||||||
from src.middlewares.access import AccessMiddleware
|
|
||||||
from src.utils.account_input import parse_cashier_records
|
|
||||||
|
|
||||||
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]
|
|
||||||
buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
|
|
||||||
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(NumericCallback('cash_add:'))
|
|
||||||
async def choose_draw(callback: CallbackQuery, state: FSMContext):
|
|
||||||
lottery_id = int(callback.data.split(":")[1])
|
|
||||||
await state.clear()
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
|
||||||
if not lottery or not lottery.is_active or lottery.is_completed:
|
|
||||||
await callback.answer("Розыгрыш закрыт или удалён. Откройте /cashier заново.", show_alert=True)
|
|
||||||
return
|
|
||||||
await state.update_data(lottery_id=lottery_id)
|
|
||||||
await state.set_state(CashierStates.accounts)
|
|
||||||
await callback.answer()
|
|
||||||
await callback.message.answer(
|
|
||||||
"Отправьте клубную карту и счёт клиента, по одной паре на строку:\n"
|
|
||||||
"0007 11-22-33-44-55-66-77\n"
|
|
||||||
"0008 88-99-00-11-22-33-44\n\n"
|
|
||||||
"Карта обязательна для каждого счёта. /cancel — отмена."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@cashier_router.message(CashierStates.accounts, F.text, NotCommand())
|
|
||||||
async def add_accounts(message: Message, state: FSMContext):
|
|
||||||
accounts, errors = parse_cashier_records(message.text)
|
|
||||||
if errors:
|
|
||||||
await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
|
||||||
return
|
|
||||||
if not accounts or len(accounts) > 1000:
|
|
||||||
await message.answer("Введите от 1 до 1000 строк в формате КАРТА СЧЁТ: 0007 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, require_card=True)
|
|
||||||
await state.clear()
|
|
||||||
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
|
|
||||||
+ (f"Привязано существующих участий: {result['linked']}.\n" if result['linked'] else "")
|
|
||||||
+ "\n".join(result["errors"][:10]))
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Обработчики пользовательских сообщений в чате"""
|
"""Обработчики пользовательских сообщений в чате"""
|
||||||
from src.filters.dialog_input import NotCommand
|
import logging
|
||||||
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.exceptions import TelegramBadRequest, TelegramForbiddenError
|
|
||||||
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
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
@@ -15,7 +11,6 @@ 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 (
|
||||||
@@ -27,14 +22,20 @@ from src.core.chat_services import (
|
|||||||
from src.core.services import UserService
|
from src.core.services import UserService
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
|
from src.core.delivery import delivery_service
|
||||||
from src.utils.account_utils import parse_accounts_from_message
|
from src.utils.account_utils import parse_accounts_from_message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ChatStates(StatesGroup):
|
class ChatStates(StatesGroup):
|
||||||
"""Состояния для работы в чате"""
|
"""Состояния для работы в чате"""
|
||||||
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:
|
||||||
@@ -91,7 +92,7 @@ async def enter_chat(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
|
||||||
async def exit_chat_command(message: Message, state: FSMContext):
|
async def exit_chat_command(message: Message, state: FSMContext):
|
||||||
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
||||||
await exit_chat(message, state)
|
await exit_chat(message, state)
|
||||||
@@ -101,10 +102,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, actor=callback.from_user)
|
await exit_chat(callback.message, state)
|
||||||
|
|
||||||
|
|
||||||
async def exit_chat(message: Message, state: FSMContext, actor=None):
|
async def exit_chat(message: Message, state: FSMContext):
|
||||||
"""Общая функция выхода из чата"""
|
"""Общая функция выхода из чата"""
|
||||||
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
|
||||||
@@ -115,9 +116,9 @@ async def exit_chat(message: Message, state: FSMContext, actor=None):
|
|||||||
|
|
||||||
# Получаем информацию о пользователе
|
# Получаем информацию о пользователе
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
user = await UserService.get_user_by_telegram_id(session, (actor or message.from_user).id)
|
user = await UserService.get_user_by_telegram_id(session, 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 = await is_admin((actor or message.from_user).id)
|
is_admin_user = message.from_user.id in ADMIN_IDS
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||||
@@ -141,7 +142,7 @@ async def exit_chat(message: Message, state: FSMContext, actor=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(ChatStates.in_chat), F.text, NotCommand())
|
@router.message(StateFilter(ChatStates.in_chat), F.text)
|
||||||
async def check_exit_keywords(message: Message, state: FSMContext):
|
async def check_exit_keywords(message: Message, state: FSMContext):
|
||||||
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
||||||
import logging
|
import logging
|
||||||
@@ -157,8 +158,15 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
# Выходим из чата и показываем главное меню
|
# Выходим из чата и показываем главное меню
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
from main import cmd_start
|
from src.components.ui import UserUI
|
||||||
await cmd_start(message, state)
|
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"🏠 <b>Главное меню</b>\n\n"
|
||||||
|
"Вы вышли из режима чата.",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
return # Не обрабатываем дальше
|
return # Не обрабатываем дальше
|
||||||
else:
|
else:
|
||||||
# Для /exit просто выходим
|
# Для /exit просто выходим
|
||||||
@@ -167,7 +175,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
||||||
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
||||||
if _is_message_processed(message.chat.id, message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -175,7 +183,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
||||||
# Пропускаем для account_router (который идет после chat_router)
|
# Пропускаем для account_router (который идет после chat_router)
|
||||||
if await is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
if 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, выбрасываем исключение для пропуска в следующий обработчик
|
||||||
@@ -183,14 +191,13 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
raise SkipHandler()
|
raise SkipHandler()
|
||||||
|
|
||||||
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
||||||
if message.reply_to_message and await is_admin(message.from_user.id):
|
if message.reply_to_message and 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:
|
||||||
@@ -268,7 +275,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 await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
|
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
|
||||||
if command in admin_commands:
|
if command in admin_commands:
|
||||||
return
|
return
|
||||||
@@ -287,7 +294,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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -311,7 +318,6 @@ 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,
|
||||||
@@ -329,7 +335,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику доставки только админам
|
# Показываем статистику доставки только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ Сообщение разослано!\n"
|
f"✅ Сообщение разослано!\n"
|
||||||
f"📤 Доставлено: {success}\n"
|
f"📤 Доставлено: {success}\n"
|
||||||
@@ -368,21 +374,20 @@ BATCH_DELAY = 1.0 # Задержка между пакетами в секун
|
|||||||
_processed_messages: deque = deque(maxlen=100)
|
_processed_messages: deque = deque(maxlen=100)
|
||||||
|
|
||||||
|
|
||||||
def _is_message_processed(chat_id: int, message_id: int) -> bool:
|
def _is_message_processed(message_id: int) -> bool:
|
||||||
"""Проверка, было ли сообщение уже обработано"""
|
"""Проверка, было ли сообщение уже обработано"""
|
||||||
key = (chat_id, message_id)
|
if message_id in _processed_messages:
|
||||||
if key in _processed_messages:
|
|
||||||
return True
|
return True
|
||||||
_processed_messages.append(key)
|
_processed_messages.append(message_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_users(session: AsyncSession) -> List:
|
async def get_all_active_users(session: AsyncSession) -> List:
|
||||||
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
|
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
|
||||||
from sqlalchemy import select
|
users = await UserService.get_all_users(session)
|
||||||
from src.core.models import BlockedUser, User
|
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным
|
||||||
unavailable = select(BlockedUser.telegram_id).where(BlockedUser.is_active.is_(True))
|
# Они все имеют право общаться в чате (главное - что они вошли в чат)
|
||||||
return list((await session.scalars(select(User).where(User.telegram_id.not_in(unavailable)))).all())
|
return users
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_message_with_scheduler(
|
async def broadcast_message_with_scheduler(
|
||||||
@@ -410,7 +415,6 @@ async def broadcast_message_with_scheduler(
|
|||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
users = await get_all_active_users(session)
|
users = await get_all_active_users(session)
|
||||||
users = [user for user in users if user.telegram_id != message.bot.id]
|
|
||||||
|
|
||||||
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
||||||
|
|
||||||
@@ -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 or u.is_admin)]
|
users = [u for u in users if u.telegram_id in ADMIN_IDS]
|
||||||
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 or recipient_user.is_admin):
|
if recipient_user.telegram_id in ADMIN_IDS:
|
||||||
# Админы видят полную информацию: 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 or sender_user.is_admin):
|
if sender_user.telegram_id in ADMIN_IDS:
|
||||||
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,52 +482,195 @@ 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, retry: bool = True) -> Optional[int]:
|
||||||
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
|
|
||||||
"""
|
"""
|
||||||
Отправить сообщение конкретному пользователю.
|
Отправить сообщение конкретному пользователю.
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
"""
|
"""
|
||||||
try:
|
result = await delivery_service.send_with_guard(
|
||||||
sent_msg = await copy_preserving_entities(message, user_telegram_id)
|
user_telegram_id,
|
||||||
return sent_msg.message_id
|
lambda: message.copy_to(user_telegram_id),
|
||||||
except Exception as e:
|
retry=retry,
|
||||||
print(f"Failed to send message to {user_telegram_id}: {public_error(e)}")
|
)
|
||||||
return None
|
if result.success:
|
||||||
|
return result.telegram_message.message_id
|
||||||
|
|
||||||
@background_delivery
|
logger.warning(
|
||||||
async def _copy_with_sender(message: Message, recipient_id: int, sender_info: str):
|
"Не удалось отправить сообщение %s: %s",
|
||||||
"""Preserve Telegram entities and keep headers within message/caption limits."""
|
user_telegram_id,
|
||||||
try:
|
result.status,
|
||||||
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
|
|
||||||
return sent.message_id
|
|
||||||
except (TelegramForbiddenError, TelegramBadRequest) as error:
|
|
||||||
error_text = str(error).lower()
|
|
||||||
unavailable = isinstance(error, TelegramForbiddenError) or any(
|
|
||||||
reason in error_text for reason in ("chat not found", "user not found", "user is deactivated")
|
|
||||||
)
|
)
|
||||||
if unavailable:
|
|
||||||
from src.core.broadcast_services import BroadcastService
|
|
||||||
reason = "blocked_bot" if isinstance(error, TelegramForbiddenError) else "chat_not_found"
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await BroadcastService().mark_user_blocked(session, recipient_id, reason, str(error))
|
|
||||||
return None
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning("Telegram rejected chat message content: %s", error)
|
|
||||||
return None
|
|
||||||
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(
|
||||||
return await _copy_with_sender(message, user_telegram_id, sender_info)
|
message: Message,
|
||||||
|
user_telegram_id: int,
|
||||||
|
sender_info: str,
|
||||||
|
retry: bool = True,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение обычному пользователю с информацией об отправителе.
|
||||||
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
|
"""
|
||||||
|
# Формируем текст с информацией об отправителе
|
||||||
|
header = f"📨 <b>{sender_info}:</b>\n\n"
|
||||||
|
|
||||||
|
async def send_message():
|
||||||
|
if message.text:
|
||||||
|
return await message.bot.send_message(
|
||||||
|
user_telegram_id,
|
||||||
|
header + message.text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.photo:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_photo(
|
||||||
|
user_telegram_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_video(
|
||||||
|
user_telegram_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_document(
|
||||||
|
user_telegram_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.animation:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_animation(
|
||||||
|
user_telegram_id,
|
||||||
|
animation=message.animation.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.sticker:
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
|
||||||
|
elif message.voice:
|
||||||
|
return await message.bot.send_voice(
|
||||||
|
user_telegram_id,
|
||||||
|
voice=message.voice.file_id,
|
||||||
|
caption=header,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video_note:
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
|
||||||
|
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.copy_to(user_telegram_id)
|
||||||
|
|
||||||
|
result = await delivery_service.send_with_guard(
|
||||||
|
user_telegram_id,
|
||||||
|
send_message,
|
||||||
|
retry=retry,
|
||||||
|
)
|
||||||
|
if result.success:
|
||||||
|
return result.telegram_message.message_id
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось отправить сообщение %s: %s",
|
||||||
|
user_telegram_id,
|
||||||
|
result.status,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
async def _send_message_to_admin_with_sender(
|
||||||
return await _copy_with_sender(message, admin_telegram_id, sender_info)
|
message: Message,
|
||||||
|
admin_telegram_id: int,
|
||||||
|
sender_info: str,
|
||||||
|
retry: bool = True,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение админу с информацией об отправителе.
|
||||||
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
|
"""
|
||||||
|
# Формируем текст с информацией об отправителе
|
||||||
|
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
||||||
|
|
||||||
|
async def send_message():
|
||||||
|
if message.text:
|
||||||
|
return await message.bot.send_message(
|
||||||
|
admin_telegram_id,
|
||||||
|
header + message.text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.photo:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_photo(
|
||||||
|
admin_telegram_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_video(
|
||||||
|
admin_telegram_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_document(
|
||||||
|
admin_telegram_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.animation:
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
return await message.bot.send_animation(
|
||||||
|
admin_telegram_id,
|
||||||
|
animation=message.animation.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.sticker:
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
||||||
|
elif message.voice:
|
||||||
|
return await message.bot.send_voice(
|
||||||
|
admin_telegram_id,
|
||||||
|
voice=message.voice.file_id,
|
||||||
|
caption=header,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video_note:
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
||||||
|
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
return await message.copy_to(admin_telegram_id)
|
||||||
|
|
||||||
|
result = await delivery_service.send_with_guard(
|
||||||
|
admin_telegram_id,
|
||||||
|
send_message,
|
||||||
|
retry=retry,
|
||||||
|
)
|
||||||
|
if result.success:
|
||||||
|
return result.telegram_message.message_id
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось отправить сообщение админу %s: %s",
|
||||||
|
admin_telegram_id,
|
||||||
|
result.status,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
||||||
@@ -533,23 +680,23 @@ 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}: {public_error(e)}")
|
logger.warning(f"Не удалось переслать сообщение в канал {channel_id}: {e}")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.photo, StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(F.photo, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_photo_message(message: Message, state: FSMContext):
|
async def handle_photo_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик фото"""
|
"""Обработчик фото"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.chat.id, message.message_id):
|
if _is_message_processed(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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -570,7 +717,6 @@ 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,
|
||||||
@@ -588,7 +734,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -608,18 +754,18 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Фото переслано в канал")
|
await message.answer("✅ Фото переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.video, StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(F.video, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_video_message(message: Message, state: FSMContext):
|
async def handle_video_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик видео"""
|
"""Обработчик видео"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.chat.id, message.message_id):
|
if _is_message_processed(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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -637,7 +783,6 @@ 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,
|
||||||
@@ -655,7 +800,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -675,18 +820,18 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Видео переслано в канал")
|
await message.answer("✅ Видео переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.document, StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(F.document, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_document_message(message: Message, state: FSMContext):
|
async def handle_document_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик документов"""
|
"""Обработчик документов"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.chat.id, message.message_id):
|
if _is_message_processed(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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -704,7 +849,6 @@ 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,
|
||||||
@@ -722,7 +866,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -742,18 +886,18 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Документ переслан в канал")
|
await message.answer("✅ Документ переслан в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.animation, StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(F.animation, StateFilter(ChatStates.in_chat))
|
||||||
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.chat.id, message.message_id):
|
if _is_message_processed(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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -771,7 +915,6 @@ 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,
|
||||||
@@ -789,7 +932,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -809,18 +952,18 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Анимация переслана в канал")
|
await message.answer("✅ Анимация переслана в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.sticker, StateFilter(ChatStates.in_chat), NotCommand())
|
@router.message(F.sticker, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_sticker_message(message: Message, state: FSMContext):
|
async def handle_sticker_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик стикеров"""
|
"""Обработчик стикеров"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.chat.id, message.message_id):
|
if _is_message_processed(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=await is_admin(message.from_user.id)
|
is_admin=is_admin(message.from_user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not can_send:
|
if not can_send:
|
||||||
@@ -838,7 +981,6 @@ 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,
|
||||||
@@ -855,7 +997,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Показываем статистику только админам
|
# Показываем статистику только админам
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||||
|
|
||||||
elif settings.mode == 'forward':
|
elif settings.mode == 'forward':
|
||||||
@@ -874,7 +1016,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Стикер переслан в канал")
|
await message.answer("✅ Стикер переслан в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.voice)
|
@router.message(F.voice, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_voice_message(message: Message):
|
async def handle_voice_message(message: Message):
|
||||||
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
|
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
|
||||||
await message.answer(
|
await message.answer(
|
||||||
@@ -884,7 +1026,7 @@ async def handle_voice_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.audio)
|
@router.message(F.audio, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_audio_message(message: Message):
|
async def handle_audio_message(message: Message):
|
||||||
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
|
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
|
||||||
await message.answer(
|
await message.answer(
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Обработчики справки и помощи пользователям"""
|
"""Обработчики справки и помощи пользователям"""
|
||||||
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 src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
from aiogram.filters import Command
|
||||||
from src.core.config import ADMIN_IDS
|
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')
|
||||||
@@ -239,7 +241,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 = await is_admin(user_id)
|
is_user_admin = is_admin(user_id)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"⚙️ <b>Список команд бота</b>\n\n"
|
"⚙️ <b>Список команд бота</b>\n\n"
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
"""Explain unmatched input instead of silently dropping it or broadcasting it."""
|
|
||||||
from aiogram import F, Router
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import Message, CallbackQuery
|
|
||||||
|
|
||||||
input_fallback_router = Router(name="input_fallback")
|
|
||||||
|
|
||||||
|
|
||||||
@input_fallback_router.callback_query()
|
|
||||||
async def unmatched_button(callback: CallbackQuery):
|
|
||||||
await callback.answer("Кнопка устарела или недоступна. Откройте меню заново через /start.", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@input_fallback_router.message(F.text.lstrip().startswith("/"))
|
|
||||||
async def unknown_command(message: Message):
|
|
||||||
await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.")
|
|
||||||
|
|
||||||
|
|
||||||
@input_fallback_router.message()
|
|
||||||
async def unmatched_input(message: Message, state: FSMContext):
|
|
||||||
if await state.get_state():
|
|
||||||
await message.answer("Этот ввод не подходит для текущего шага. Отправьте данные в формате из подсказки или используйте предложенные кнопки. /cancel — отмена.")
|
|
||||||
else:
|
|
||||||
await message.answer("Сейчас бот не ожидает эти данные. /start — главное меню, /register — регистрация. Для переписки сначала откройте чат.")
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Хэндлеры для управления сообщениями администратором
|
Хэндлеры для управления сообщениями администратором
|
||||||
"""
|
"""
|
||||||
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
|
||||||
from aiogram.filters import StateFilter
|
from aiogram.filters import Command
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
@@ -20,6 +17,9 @@ 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 +28,7 @@ async def delete_replied_message(message: Message):
|
|||||||
Удаление сообщения по команде /delete (регистронезависимо)
|
Удаление сообщения по команде /delete (регистронезависимо)
|
||||||
Работает только если команда является ответом на сообщение бота
|
Работает только если команда является ответом на сообщение бота
|
||||||
"""
|
"""
|
||||||
if not await is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -48,7 +48,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"❌ Не удалось удалить сообщение: {public_error(e)}")
|
await message.answer(f"❌ Не удалось удалить сообщение: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.callback_query(F.data == "delete_message")
|
@message_admin_router.callback_query(F.data == "delete_message")
|
||||||
@@ -56,7 +56,7 @@ async def delete_message_callback(callback: CallbackQuery):
|
|||||||
"""
|
"""
|
||||||
Удаление сообщения по нажатию кнопки
|
Удаление сообщения по нажатию кнопки
|
||||||
"""
|
"""
|
||||||
if not await is_admin(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
|
||||||
|
|
||||||
@@ -66,21 +66,21 @@ 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"❌ Ошибка: {public_error(e)}", show_alert=True)
|
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# Функция-фильтр для проверки триггерных слов
|
# Функция-фильтр для проверки триггерных слов
|
||||||
async def is_delete_trigger(message: Message) -> bool:
|
def is_delete_trigger(message: Message) -> bool:
|
||||||
"""Проверяет, является ли сообщение триггером для удаления"""
|
"""Проверяет, является ли сообщение триггером для удаления"""
|
||||||
if not message.text:
|
if not message.text:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
text_lower = message.text.lower().strip()
|
text_lower = message.text.lower().strip()
|
||||||
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
||||||
return text_lower in triggers and await is_admin(message.from_user.id)
|
return any(trigger in text_lower for trigger in triggers)
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.message(F.reply_to_message, is_delete_trigger, StateFilter(None, "ChatStates:in_chat", "P2PChatStates:chatting"))
|
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
|
||||||
async def quick_delete_replied_message(message: Message):
|
async def quick_delete_replied_message(message: Message):
|
||||||
"""
|
"""
|
||||||
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
||||||
@@ -93,7 +93,7 @@ async def quick_delete_replied_message(message: Message):
|
|||||||
|
|
||||||
Удаляет сообщение у всех получателей broadcast рассылки
|
Удаляет сообщение у всех получателей broadcast рассылки
|
||||||
"""
|
"""
|
||||||
if not await is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
return # Не админ - пропускаем
|
return # Не админ - пропускаем
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -115,8 +115,7 @@ 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,43 +0,0 @@
|
|||||||
"""Role-specific operator manuals, included in every release."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aiogram import F, Router
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from aiogram.types import CallbackQuery, FSInputFile, Message
|
|
||||||
|
|
||||||
from src.core.access import get_role
|
|
||||||
|
|
||||||
operator_help_router = Router(name="operator_help")
|
|
||||||
GUIDES = {
|
|
||||||
"admin": ("ADMIN_GUIDE.md", "Инструкция администратора", {"admin", "super_admin"}),
|
|
||||||
"cashier": ("CASHIER_GUIDE.md", "Инструкция кассира", {"cashier", "admin", "super_admin"}),
|
|
||||||
"system": ("SYSTEM_ADMIN_GUIDE.md", "Инструкция системного администратора", {"super_admin"}),
|
|
||||||
}
|
|
||||||
DOCS = Path(__file__).resolve().parents[2] / "docs"
|
|
||||||
|
|
||||||
|
|
||||||
@operator_help_router.message(Command("my_id"))
|
|
||||||
async def own_id(message: Message):
|
|
||||||
await message.answer(f"Ваш Telegram ID: {message.from_user.id}")
|
|
||||||
|
|
||||||
|
|
||||||
async def send_guide(event, kind):
|
|
||||||
spec = GUIDES.get(kind)
|
|
||||||
if spec is None or await get_role(event.from_user.id) not in spec[2]:
|
|
||||||
await event.answer("Недостаточно прав", **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
|
|
||||||
return
|
|
||||||
message = event.message if isinstance(event, CallbackQuery) else event
|
|
||||||
if isinstance(event, CallbackQuery):
|
|
||||||
await event.answer()
|
|
||||||
await message.answer_document(FSInputFile(DOCS / spec[0]), caption=spec[1])
|
|
||||||
|
|
||||||
|
|
||||||
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
|
|
||||||
async def help_command(message: Message):
|
|
||||||
kind = message.text.split()[0].split("@")[0].lower().removeprefix("/").removesuffix("_help")
|
|
||||||
await send_guide(message, kind)
|
|
||||||
|
|
||||||
|
|
||||||
@operator_help_router.callback_query(F.data.startswith("guide:"))
|
|
||||||
async def help_callback(callback: CallbackQuery):
|
|
||||||
await send_guide(callback, callback.data.split(":", 1)[1])
|
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
"""Обработчики P2P чата между пользователями"""
|
"""Обработчики P2P чата между пользователями"""
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
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 StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.core.p2p_services import P2PMessageService
|
from src.core.p2p_services import P2PMessageService
|
||||||
@@ -20,6 +15,7 @@ from src.core.services import UserService
|
|||||||
from src.core.models import User
|
from src.core.models import User
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
|
from src.core.delivery import delivery_service
|
||||||
|
|
||||||
|
|
||||||
router = Router(name='p2p_chat_router')
|
router = Router(name='p2p_chat_router')
|
||||||
@@ -31,6 +27,9 @@ 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:
|
||||||
@@ -108,7 +107,7 @@ async def show_chat_menu(message: Message, state: FSMContext):
|
|||||||
)]
|
)]
|
||||||
]
|
]
|
||||||
|
|
||||||
if await is_admin(message.from_user.id):
|
if is_admin(message.from_user.id):
|
||||||
buttons.append([InlineKeyboardButton(
|
buttons.append([InlineKeyboardButton(
|
||||||
text="📢 Написать всем (broadcast)",
|
text="📢 Написать всем (broadcast)",
|
||||||
callback_data="p2p:broadcast"
|
callback_data="p2p:broadcast"
|
||||||
@@ -127,9 +126,13 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Получаем всех зарегистрированных пользователей кроме себя
|
result = await session.execute(
|
||||||
users = await UserService.get_all_users(session)
|
select(User)
|
||||||
users = [u for u in users if u.telegram_id != callback.from_user.id and (u.is_registered or u.is_admin or u.telegram_id in ADMIN_IDS)]
|
.where(User.telegram_id != callback.from_user.id, User.is_registered == True)
|
||||||
|
.order_by(User.created_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||||
@@ -137,9 +140,9 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
# Создаём кнопки с пользователями (по 1 на строку)
|
# Создаём кнопки с пользователями (по 1 на строку)
|
||||||
buttons = []
|
buttons = []
|
||||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
for user in users:
|
||||||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||||
if user.club_card_number and await is_admin(callback.from_user.id):
|
if user.club_card_number:
|
||||||
display_name += f" (карта: {user.club_card_number})"
|
display_name += f" (карта: {user.club_card_number})"
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(
|
buttons.append([InlineKeyboardButton(
|
||||||
@@ -204,10 +207,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, await is_admin(sender.telegram_id))
|
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
||||||
|
|
||||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||||
text += f"• {escape(sender_name)}: {escape(msg_text)}\n"
|
text += f"• {sender_name}: {msg_text}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
|
|
||||||
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||||||
@@ -270,12 +273,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>{escape(peer_name)}</b>"
|
text += f"{icon} <b>{peer_name}</b>"
|
||||||
# Показываем номер карты если есть
|
# Показываем номер карты если есть
|
||||||
if peer.club_card_number and await is_admin(callback.from_user.id):
|
if peer.club_card_number:
|
||||||
text += f" (карта: {escape(peer.club_card_number)})"
|
text += f" (карта: {peer.club_card_number})"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
text += f" {escape(preview)}\n"
|
text += f" {preview}\n"
|
||||||
if unread > 0:
|
if unread > 0:
|
||||||
text += f" 📨 Непрочитанных: {unread}\n"
|
text += f" 📨 Непрочитанных: {unread}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
@@ -358,7 +361,7 @@ async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
# Обработчик сообщений в состоянии chatting
|
# Обработчик сообщений в состоянии chatting
|
||||||
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document, NotCommand())
|
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
||||||
async def handle_p2p_message(message: Message, state: FSMContext):
|
async def handle_p2p_message(message: Message, state: FSMContext):
|
||||||
"""Обработка P2P сообщения от пользователя"""
|
"""Обработка P2P сообщения от пользователя"""
|
||||||
import logging
|
import logging
|
||||||
@@ -387,13 +390,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 await is_admin(sender.telegram_id):
|
if sender.is_admin:
|
||||||
sender_name = "АДМИН"
|
sender_name = "АДМИН"
|
||||||
else:
|
else:
|
||||||
sender_name = sender.nickname or (f"@{sender.username}" if sender.username else sender.first_name) or "Участник"
|
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
||||||
|
|
||||||
# Добавляем карту если получатель админ
|
# Добавляем карту если получатель админ
|
||||||
if recipient and await is_admin(recipient.telegram_id) and sender.club_card_number:
|
if recipient and recipient.is_admin and sender.club_card_number:
|
||||||
sender_name += f" (карта: {sender.club_card_number})"
|
sender_name += f" (карта: {sender.club_card_number})"
|
||||||
|
|
||||||
# Определяем тип сообщения
|
# Определяем тип сообщения
|
||||||
@@ -414,13 +417,49 @@ 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()
|
async def send_to_recipient():
|
||||||
|
if message_type == "text":
|
||||||
|
return await message.bot.send_message(
|
||||||
|
recipient_telegram_id,
|
||||||
|
f"<b>{sender_name}</b>\n\n{text}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "photo":
|
||||||
|
return await message.bot.send_photo(
|
||||||
|
recipient_telegram_id,
|
||||||
|
photo=file_id,
|
||||||
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "video":
|
||||||
|
return await message.bot.send_video(
|
||||||
|
recipient_telegram_id,
|
||||||
|
video=file_id,
|
||||||
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message_type == "document":
|
||||||
|
return await message.bot.send_document(
|
||||||
|
recipient_telegram_id,
|
||||||
|
document=file_id,
|
||||||
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
# Отправляем сообщение получателю
|
return await message.copy_to(recipient_telegram_id)
|
||||||
try:
|
|
||||||
sent = await copy_preserving_entities(message, recipient_telegram_id, sender_name)
|
|
||||||
|
|
||||||
# Сохраняем в БД
|
delivery = await delivery_service.send_with_guard(
|
||||||
|
recipient_telegram_id,
|
||||||
|
send_to_recipient,
|
||||||
|
)
|
||||||
|
if not delivery.success:
|
||||||
|
if delivery.status in {"blocked_bot", "deactivated", "not_found", "chat_not_found"}:
|
||||||
|
await message.answer("❌ Получатель недоступен: он мог заблокировать бота")
|
||||||
|
else:
|
||||||
|
await message.answer("❌ Не удалось доставить сообщение. Попробуйте позже")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Сохраняем в БД только реально доставленное сообщение.
|
||||||
await P2PMessageService.send_message(
|
await P2PMessageService.send_message(
|
||||||
session,
|
session,
|
||||||
sender_id=sender.id,
|
sender_id=sender.id,
|
||||||
@@ -429,20 +468,7 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
text=text,
|
text=text,
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
sender_message_id=message.message_id,
|
sender_message_id=message.message_id,
|
||||||
recipient_message_id=sent.message_id
|
recipient_message_id=delivery.telegram_message.message_id
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer("✅ Сообщение доставлено")
|
await message.answer("✅ Сообщение доставлено")
|
||||||
|
|
||||||
except Exception as 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,9 +1,7 @@
|
|||||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||||
from src.utils.input_validation import numeric_id
|
|
||||||
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 src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
from aiogram.filters import Command
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import random
|
import random
|
||||||
@@ -15,6 +13,7 @@ from src.core.services import LotteryService
|
|||||||
from src.core.models import User, Winner
|
from src.core.models import User, Winner
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
from src.core.permissions import admin_only
|
from src.core.permissions import admin_only
|
||||||
|
from src.core.delivery import delivery_service
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -37,7 +36,7 @@ async def check_unclaimed_winners(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
lottery_id = int(parts[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
@@ -72,10 +71,7 @@ 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 часа
|
||||||
created = winner.created_at
|
time_passed = now - 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,
|
||||||
@@ -121,29 +117,203 @@ 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"❌ Ошибка: {public_error(e)}")
|
await message.answer(f"❌ Ошибка: {str(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
|
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
|
||||||
parts = message.text.split()
|
Формат: /redraw <lottery_id>
|
||||||
if len(parts) != 2 or not parts[1].isdigit():
|
"""
|
||||||
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
|
||||||
except ValueError as error:
|
|
||||||
await message.answer(str(error))
|
|
||||||
return
|
|
||||||
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 часа.")
|
|
||||||
|
|
||||||
|
parts = message.text.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Неверный формат команды\n\n"
|
||||||
|
"Используйте: /redraw <lottery_id>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
delivery = await delivery_service.send_with_guard(
|
||||||
|
owner.telegram_id,
|
||||||
|
lambda: message.bot.send_message(
|
||||||
|
owner.telegram_id,
|
||||||
|
notification_message,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delivery.success:
|
||||||
|
new_winner.is_notified = True
|
||||||
|
await session.commit()
|
||||||
|
else:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Не удалось уведомить нового победителя %s: %s",
|
||||||
|
owner.telegram_id,
|
||||||
|
delivery.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем отчет для админа
|
||||||
|
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_"))
|
||||||
@@ -180,16 +350,10 @@ 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:
|
||||||
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) - 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 часа). Приз будет разыгран заново.",
|
||||||
@@ -198,17 +362,8 @@ async def confirm_winner_callback(callback_query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Подтверждаем выигрыш
|
# Подтверждаем выигрыш
|
||||||
from src.core.models import Lottery
|
winner.is_claimed = True
|
||||||
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == winner.lottery_id)):
|
winner.claimed_at = datetime.now(timezone.utc)
|
||||||
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()
|
||||||
|
|
||||||
# Получаем данные о розыгрыше и пользователе
|
# Получаем данные о розыгрыше и пользователе
|
||||||
@@ -270,9 +425,12 @@ async def confirm_winner_callback(callback_query):
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from src.core.config import BOT_TOKEN
|
from src.core.config import BOT_TOKEN
|
||||||
bot = Bot(token=BOT_TOKEN)
|
bot = Bot(token=BOT_TOKEN)
|
||||||
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
|
await delivery_service.send_with_guard(
|
||||||
|
admin_id,
|
||||||
|
lambda: bot.send_message(admin_id, admin_text, parse_mode="Markdown"),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {public_error(e)}")
|
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
|
||||||
|
|
||||||
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
"""Обработчики для регистрации пользователей"""
|
"""Обработчики для регистрации пользователей"""
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
from src.utils.errors import public_error
|
|
||||||
from src.utils.input_validation import club_card, phone_number
|
|
||||||
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 Command, StateFilter
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -23,22 +19,6 @@ 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 = [
|
||||||
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
||||||
@@ -76,8 +56,6 @@ 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, ""
|
||||||
|
|
||||||
|
|
||||||
@@ -90,12 +68,29 @@ 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()
|
"""Начать процесс регистрации"""
|
||||||
await begin_registration(callback.message, state, actor=callback.from_user)
|
logger.info(f"Получен запрос на регистрацию от пользователя {callback.from_user.id}")
|
||||||
|
|
||||||
|
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, NotCommand())
|
|
||||||
async def process_nickname(message: Message, state: FSMContext):
|
async def process_nickname(message: Message, state: FSMContext):
|
||||||
"""Обработка никнейма"""
|
"""Обработка никнейма"""
|
||||||
nickname = message.text.strip()
|
nickname = message.text.strip()
|
||||||
@@ -113,59 +108,62 @@ async def process_nickname(message: Message, state: FSMContext):
|
|||||||
# Сохраняем никнейм
|
# Сохраняем никнейм
|
||||||
await state.update_data(nickname=nickname)
|
await state.update_data(nickname=nickname)
|
||||||
|
|
||||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
|
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
|
||||||
"Шаг 2 из 3: Клубная карта\n\n"
|
"Шаг 2 из 3: Клубная карта\n\n"
|
||||||
"📝 Введите номер вашей клубной карты:"
|
"📝 Введите номер вашей клубной карты:"
|
||||||
)
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text, NotCommand())
|
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
|
||||||
async def process_club_card(message: Message, state: FSMContext):
|
async def process_club_card(message: Message, state: FSMContext):
|
||||||
"""Обработка номера клубной карты"""
|
"""Обработка номера клубной карты"""
|
||||||
try:
|
club_card_number = message.text.strip()
|
||||||
club_card_number = club_card(message.text)
|
|
||||||
except ValueError as error:
|
|
||||||
await message.answer(f"❌ {error}\nПопробуйте ещё раз.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Проверяем, не занята ли карта
|
# Проверяем, не занята ли карта
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
||||||
|
|
||||||
if existing_user and existing_user.telegram_id != message.from_user.id:
|
if existing_user:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
||||||
"Если это ваша карта, обратитесь к администратору."
|
"Если это ваша карта, обратитесь к администратору."
|
||||||
)
|
)
|
||||||
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(club_card_number=club_card_number)
|
await state.update_data(club_card_number=club_card_number)
|
||||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Шаг 3 из 3: Телефон\n\n"
|
"Шаг 3 из 3: Телефон\n\n"
|
||||||
"📱 Введите ваш номер телефона\n"
|
"📱 Введите ваш номер телефона\n"
|
||||||
"(или отправьте '-' чтобы пропустить):"
|
"(или отправьте '-' чтобы пропустить):"
|
||||||
)
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text, NotCommand())
|
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||||
async def process_phone(message: Message, state: FSMContext):
|
async def process_phone(message: Message, state: FSMContext):
|
||||||
"""Обработка номера телефона"""
|
"""Обработка номера телефона"""
|
||||||
try:
|
phone_input = message.text.strip()
|
||||||
phone = phone_number(message.text)
|
|
||||||
except ValueError as error:
|
# Проверяем, не отправил ли пользователь просто "-"
|
||||||
await message.answer(f"❌ {error}")
|
if phone_input == "-":
|
||||||
|
phone = None
|
||||||
|
else:
|
||||||
|
# Валидируем телефон: не должно быть пустых или некорректных значений
|
||||||
|
if not phone_input:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Неверный номер телефона.\n\n"
|
||||||
|
"Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
phone = phone_input
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
club_card_number = data.get('club_card_number')
|
club_card_number = data['club_card_number']
|
||||||
nickname = data.get('nickname')
|
nickname = data.get('nickname')
|
||||||
if not club_card_number or not nickname:
|
|
||||||
await begin_registration(message, state)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -173,21 +171,15 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
session,
|
session,
|
||||||
telegram_id=message.from_user.id,
|
telegram_id=message.from_user.id,
|
||||||
club_card_number=club_card_number,
|
club_card_number=club_card_number,
|
||||||
phone=phone,
|
phone=phone
|
||||||
nickname=nickname,
|
|
||||||
)
|
)
|
||||||
except ValueError as error:
|
|
||||||
# The card may have been claimed after step 2, while this user entered a phone.
|
|
||||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
|
||||||
await message.answer(f"❌ {error}\nВведите номер клубной карты ещё раз или /cancel.")
|
|
||||||
return
|
|
||||||
except Exception as error:
|
|
||||||
logger.exception("Registration persistence failed")
|
|
||||||
await message.answer(f"❌ {public_error(error)}\nПовторите ввод телефона или /cancel.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Registration is committed even if Telegram cannot deliver the acknowledgement.
|
# Обновляем никнейм пользователя
|
||||||
await state.clear()
|
if nickname:
|
||||||
|
user.nickname = nickname
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"✅ Регистрация завершена!\n\n"
|
"✅ Регистрация завершена!\n\n"
|
||||||
f"🎭 Никнейм: {user.nickname}\n"
|
f"🎭 Никнейм: {user.nickname}\n"
|
||||||
@@ -198,6 +190,14 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
||||||
|
await state.clear()
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("my_code"))
|
@router.message(Command("my_code"))
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
"""One staff-management dialog, restricted at every step to system admins."""
|
|
||||||
from src.filters.dialog_input import NotCommand
|
|
||||||
from html import escape
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from aiogram import BaseMiddleware, F, Router
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
|
||||||
|
|
||||||
from src.core.staff_service import (ROLE_NAMES, StaffError, change_role, check_editable,
|
|
||||||
get_member, list_members, require_system_admin)
|
|
||||||
|
|
||||||
staff_router = Router(name="staff_management")
|
|
||||||
|
|
||||||
|
|
||||||
class SystemAdminMiddleware(BaseMiddleware):
|
|
||||||
async def __call__(self, handler, event, data):
|
|
||||||
try:
|
|
||||||
require_system_admin(event.from_user.id)
|
|
||||||
except StaffError as error:
|
|
||||||
await event.answer(str(error), **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
|
|
||||||
return
|
|
||||||
return await handler(event, data)
|
|
||||||
|
|
||||||
|
|
||||||
staff_router.message.middleware(SystemAdminMiddleware())
|
|
||||||
staff_router.callback_query.middleware(SystemAdminMiddleware())
|
|
||||||
|
|
||||||
|
|
||||||
class StaffStates(StatesGroup):
|
|
||||||
target = State()
|
|
||||||
confirm = State()
|
|
||||||
|
|
||||||
|
|
||||||
def menu_keyboard():
|
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="Назначить администратора", callback_data="staff:role:admin")],
|
|
||||||
[InlineKeyboardButton(text="Назначить кассира", callback_data="staff:role:cashier")],
|
|
||||||
[InlineKeyboardButton(text="Снять права сотрудника", callback_data="staff:role:user")],
|
|
||||||
[InlineKeyboardButton(text="Список сотрудников", callback_data="staff:list:0")],
|
|
||||||
[InlineKeyboardButton(text="Инструкция системного администратора", callback_data="guide:system")],
|
|
||||||
[InlineKeyboardButton(text="Назад в настройки", callback_data="admin_settings")],
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
async def show_menu(message, state):
|
|
||||||
await state.clear()
|
|
||||||
await message.answer(
|
|
||||||
"👥 <b>Сотрудники и права</b>\n\n"
|
|
||||||
"Администратор: пользователи, розыгрыши, рассылки и модерация.\n"
|
|
||||||
"Кассир: счета, добавление участия и подтверждение выдачи призов.\n"
|
|
||||||
"Назначать и снимать эти роли может только системный администратор.\n\n"
|
|
||||||
"У сотрудника одна назначенная роль. Смена роли заменяет предыдущие права.",
|
|
||||||
parse_mode="HTML", reply_markup=menu_keyboard())
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(Command("staff"))
|
|
||||||
async def staff_menu(message: Message, state: FSMContext):
|
|
||||||
await show_menu(message, state)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.callback_query(F.data.in_({"staff:menu", "admin_manage_admins"}))
|
|
||||||
async def staff_menu_callback(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.answer()
|
|
||||||
await show_menu(callback.message, state)
|
|
||||||
|
|
||||||
|
|
||||||
async def request_confirmation(message, state, actor_id, target_id, role, removal_role=None):
|
|
||||||
try:
|
|
||||||
check_editable(actor_id, target_id)
|
|
||||||
member = await get_member(actor_id, target_id)
|
|
||||||
if removal_role and member.role != removal_role:
|
|
||||||
raise StaffError(f"У пользователя другая роль: {ROLE_NAMES[member.role]}. Используйте /staff.")
|
|
||||||
if member.flags == (role == "admin", role == "cashier"):
|
|
||||||
raise StaffError("У пользователя уже установлена эта роль.")
|
|
||||||
except StaffError as error:
|
|
||||||
await message.answer(str(error))
|
|
||||||
return
|
|
||||||
nonce = secrets.token_hex(8)
|
|
||||||
await state.set_data({"target_id": target_id, "role": role, "expected_flags": list(member.flags), "nonce": nonce})
|
|
||||||
await state.set_state(StaffStates.confirm)
|
|
||||||
await message.answer(
|
|
||||||
f"Сотрудник: {escape(member.name[:100])}\nTelegram ID: <code>{target_id}</code>\n"
|
|
||||||
f"Текущая роль: {ROLE_NAMES[member.role]}\nНовая роль: <b>{ROLE_NAMES[role]}</b>\n\n"
|
|
||||||
"Проверьте ID и подтвердите изменение прав.", parse_mode="HTML",
|
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[InlineKeyboardButton(text="Подтвердить изменение", callback_data=f"staff:confirm:{nonce}")],
|
|
||||||
[InlineKeyboardButton(text="Отмена", callback_data="staff:menu")],
|
|
||||||
]))
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.callback_query(F.data.startswith("staff:role:"))
|
|
||||||
async def choose_role(callback: CallbackQuery, state: FSMContext):
|
|
||||||
role = callback.data.rsplit(":", 1)[-1]
|
|
||||||
if role not in {"user", "admin", "cashier"}:
|
|
||||||
await callback.answer("Недопустимая роль", show_alert=True)
|
|
||||||
return
|
|
||||||
await state.set_data({"role": role})
|
|
||||||
await state.set_state(StaffStates.target)
|
|
||||||
await callback.answer()
|
|
||||||
await callback.message.answer("Отправьте точный Telegram ID сотрудника (число). Он должен сначала выполнить /start. Для отмены — /cancel.")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_target(text):
|
|
||||||
if text and text.isascii() and text.isdigit() and 0 < int(text) < 2**63:
|
|
||||||
return int(text)
|
|
||||||
raise StaffError("Нужен положительный числовой Telegram ID. Имя и @username не подходят.")
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(Command("add_admin", "remove_admin", "add_cashier", "remove_cashier"))
|
|
||||||
async def staff_command(message: Message, state: FSMContext):
|
|
||||||
await state.clear()
|
|
||||||
parts = message.text.split()
|
|
||||||
command = parts[0].split("@")[0].lower()
|
|
||||||
try:
|
|
||||||
target_id = parse_target(parts[1] if len(parts) == 2 else None)
|
|
||||||
except StaffError as error:
|
|
||||||
await message.answer(f"{error}\nФормат: {command} TELEGRAM_ID")
|
|
||||||
return
|
|
||||||
role = "admin" if command.endswith("admin") else "cashier"
|
|
||||||
removal = command.startswith("/remove_")
|
|
||||||
await request_confirmation(message, state, message.from_user.id, target_id,
|
|
||||||
"user" if removal else role, role if removal else None)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(StaffStates.target, F.text, NotCommand())
|
|
||||||
async def staff_target(message: Message, state: FSMContext):
|
|
||||||
try:
|
|
||||||
target_id = parse_target(message.text.strip())
|
|
||||||
except StaffError as error:
|
|
||||||
await message.answer(str(error))
|
|
||||||
return
|
|
||||||
data = await state.get_data()
|
|
||||||
await request_confirmation(message, state, message.from_user.id, target_id, data["role"])
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.callback_query(F.data.startswith("staff:confirm:"))
|
|
||||||
async def confirm_role(callback: CallbackQuery, state: FSMContext):
|
|
||||||
data = await state.get_data()
|
|
||||||
if await state.get_state() != StaffStates.confirm.state or callback.data != f"staff:confirm:{data.get('nonce')}":
|
|
||||||
await callback.answer("Подтверждение устарело. Откройте /staff заново.", show_alert=True)
|
|
||||||
return
|
|
||||||
# Consume confirmation before mutation or any Telegram request. A repeated
|
|
||||||
# click cannot reuse it even if the acknowledgement fails to reach Telegram.
|
|
||||||
await state.clear()
|
|
||||||
try:
|
|
||||||
await change_role(callback.from_user.id, data["target_id"], data["role"], data["expected_flags"])
|
|
||||||
except StaffError as error:
|
|
||||||
await callback.answer(str(error), show_alert=True)
|
|
||||||
return
|
|
||||||
await callback.answer("Права обновлены")
|
|
||||||
await callback.message.answer(f"✅ Telegram ID {data['target_id']}: {ROLE_NAMES[data['role']]}.\n"
|
|
||||||
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(StaffStates.confirm, NotCommand())
|
|
||||||
async def confirmation_pending(message: Message):
|
|
||||||
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
|
|
||||||
|
|
||||||
|
|
||||||
async def show_members(message, state, actor_id, page=0):
|
|
||||||
await state.clear()
|
|
||||||
members, total = await list_members(actor_id, page * 20)
|
|
||||||
lines = ["<b>Сотрудники и права</b>"]
|
|
||||||
for member in members:
|
|
||||||
lines.append(f"<code>{member.telegram_id}</code> — {escape(member.name[:50])}: {ROLE_NAMES[member.role]}")
|
|
||||||
buttons = []
|
|
||||||
if page:
|
|
||||||
buttons.append(InlineKeyboardButton(text="◀️", callback_data=f"staff:list:{page - 1}"))
|
|
||||||
if (page + 1) * 20 < total:
|
|
||||||
buttons.append(InlineKeyboardButton(text="▶️", callback_data=f"staff:list:{page + 1}"))
|
|
||||||
await message.answer("\n".join(lines), parse_mode="HTML", reply_markup=InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=([buttons] if buttons else []) + [[InlineKeyboardButton(text="Назад", callback_data="staff:menu")]]))
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(Command("admins", "cashiers"))
|
|
||||||
async def staff_list_command(message: Message, state: FSMContext):
|
|
||||||
await show_members(message, state, message.from_user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_router.callback_query(F.data.startswith("staff:list:"))
|
|
||||||
async def staff_list_callback(callback: CallbackQuery, state: FSMContext):
|
|
||||||
page = callback.data.rsplit(":", 1)[-1]
|
|
||||||
if not page.isdigit() or len(page) > 6:
|
|
||||||
await callback.answer("Недопустимая страница")
|
|
||||||
return
|
|
||||||
await callback.answer()
|
|
||||||
await show_members(callback.message, state, callback.from_user.id, int(page))
|
|
||||||
|
|
||||||
|
|
||||||
# Old keyboards cannot bypass the new identity check and confirmation protocol.
|
|
||||||
@staff_router.callback_query(F.data.in_({"admin_add_admin", "admin_remove_admin", "admin_list_admins_view",
|
|
||||||
"admin_add_confirm_yes", "admin_add_confirm_no"}) |
|
|
||||||
F.data.startswith("admin_remove_select:"))
|
|
||||||
async def legacy_staff_callback(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await callback.answer("Меню обновлено. Выберите действие заново.")
|
|
||||||
await show_menu(callback.message, state)
|
|
||||||
@@ -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 = await is_admin(user_id)
|
is_admin_user = 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 await is_admin(message.from_user.id):
|
if not 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 = await is_admin(user_id)
|
is_admin_user = 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 '👤 Пользователь'}"
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -5,9 +5,11 @@ from typing import Callable, Dict, Any, Awaitable
|
|||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.activity_service import ActivityService
|
from src.core.activity_service import ActivityService
|
||||||
|
from src.core.config import ACTIVITY_UPDATE_INTERVAL_SECONDS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class ActivityMiddleware(BaseMiddleware):
|
class ActivityMiddleware(BaseMiddleware):
|
||||||
"""Middleware для обновления last_activity при каждом взаимодействии"""
|
"""Middleware для обновления last_activity при каждом взаимодействии"""
|
||||||
|
|
||||||
|
_last_updates: Dict[int, float] = {}
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
@@ -34,18 +38,36 @@ class ActivityMiddleware(BaseMiddleware):
|
|||||||
elif event.callback_query and event.callback_query.from_user:
|
elif event.callback_query and event.callback_query.from_user:
|
||||||
telegram_id = event.callback_query.from_user.id
|
telegram_id = event.callback_query.from_user.id
|
||||||
|
|
||||||
# Обновляем активность если есть telegram_id
|
# Обновляем активность не чаще заданного интервала.
|
||||||
if telegram_id:
|
if telegram_id and self._should_update(telegram_id):
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Обновляем активность
|
reactivated = await ActivityService.update_activity_and_reactivate(session, telegram_id)
|
||||||
|
if reactivated is None:
|
||||||
# Проверяем, не был ли пользователь заблокирован за неактивность
|
self._last_updates.pop(telegram_id, None)
|
||||||
# Если был - реактивируем
|
return await handler(event, data)
|
||||||
await ActivityService.reactivate_user(session, telegram_id)
|
if reactivated:
|
||||||
|
logger.info(f"Пользователь {telegram_id} реактивирован")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self._last_updates.pop(telegram_id, None)
|
||||||
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
|
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
|
||||||
|
|
||||||
# Вызываем следующий обработчик
|
# Вызываем следующий обработчик
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_update(cls, telegram_id: int) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
last_update = cls._last_updates.get(telegram_id)
|
||||||
|
if last_update and now - last_update < ACTIVITY_UPDATE_INTERVAL_SECONDS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cls._last_updates[telegram_id] = now
|
||||||
|
if len(cls._last_updates) > 10000:
|
||||||
|
cutoff = now - ACTIVITY_UPDATE_INTERVAL_SECONDS
|
||||||
|
cls._last_updates = {
|
||||||
|
user_id: updated_at
|
||||||
|
for user_id, updated_at in cls._last_updates.items()
|
||||||
|
if updated_at >= cutoff
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Parse each account record independently, preserving card/account associations."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
ACCOUNT = re.compile(r"(?<![\w-])(?:[0-9]{2}(?:[- ][0-9]{2}){6}|[0-9]{14})(?![\w-])")
|
|
||||||
CARD = re.compile(r"[0-9]{1,50}")
|
|
||||||
|
|
||||||
|
|
||||||
def card_account_entry(value):
|
|
||||||
"""Validate an explicit card/account pair, including a 14-digit card."""
|
|
||||||
from .account_utils import format_account_number
|
|
||||||
parts = value.strip().split(maxsplit=1)
|
|
||||||
if len(parts) != 2 or not CARD.fullmatch(parts[0]) or not ACCOUNT.fullmatch(parts[1]):
|
|
||||||
raise ValueError("Для каждого счёта обязательна клубная карта. Формат: КАРТА СЧЁТ, например 0007 11-22-33-44-55-66-77.")
|
|
||||||
return f"{parts[0]} {format_account_number(parts[1])}"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cashier_records(text):
|
|
||||||
# Keep the existing table/Viposnova formats, but never accept an unbound row.
|
|
||||||
entries, errors = [], []
|
|
||||||
if not any(line.strip().lower().startswith("viposnova") for line in text.splitlines()):
|
|
||||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
||||||
try:
|
|
||||||
entries = [card_account_entry(line) for line in lines]
|
|
||||||
except ValueError:
|
|
||||||
entries = []
|
|
||||||
if not entries:
|
|
||||||
entries, errors = parse_account_records(text)
|
|
||||||
checked = []
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
pair = card_account_entry(entry)
|
|
||||||
except ValueError as error:
|
|
||||||
errors.append(str(error))
|
|
||||||
continue
|
|
||||||
if pair not in checked:
|
|
||||||
checked.append(pair)
|
|
||||||
return checked, list(dict.fromkeys(errors))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_account_records(text):
|
|
||||||
from .account_utils import format_account_number
|
|
||||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
||||||
records = []
|
|
||||||
if any(line.lower().startswith("viposnova") for line in lines):
|
|
||||||
current = []
|
|
||||||
for line in lines:
|
|
||||||
if line.lower().startswith("viposnova") and current:
|
|
||||||
records.append(" ".join(current))
|
|
||||||
current = []
|
|
||||||
current.append(line)
|
|
||||||
if current:
|
|
||||||
records.append(" ".join(current))
|
|
||||||
else:
|
|
||||||
i = 0
|
|
||||||
while i < len(lines):
|
|
||||||
record = lines[i]
|
|
||||||
# A bare account followed by a card on the next line is one record.
|
|
||||||
if ACCOUNT.fullmatch(record) and i + 1 < len(lines) and CARD.fullmatch(lines[i + 1]) and not ACCOUNT.fullmatch(lines[i + 1]):
|
|
||||||
record += " " + lines[i + 1]
|
|
||||||
i += 1
|
|
||||||
records.append(record)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
entries, errors = [], []
|
|
||||||
for index, record in enumerate(records, 1):
|
|
||||||
matches = list(ACCOUNT.finditer(record))
|
|
||||||
if not matches:
|
|
||||||
errors.append(f"Запись {index}: номер счёта не распознан; нужно 14 цифр.")
|
|
||||||
continue
|
|
||||||
for match in matches:
|
|
||||||
account = format_account_number(match.group())
|
|
||||||
card = None
|
|
||||||
if len(matches) == 1:
|
|
||||||
prefix = record[:match.start()].strip()
|
|
||||||
suffix = record[match.end():].strip()
|
|
||||||
if CARD.fullmatch(prefix):
|
|
||||||
card = prefix
|
|
||||||
elif CARD.fullmatch(suffix):
|
|
||||||
card = suffix
|
|
||||||
elif record.lower().startswith("viposnova"):
|
|
||||||
candidates = [part for part in suffix.split() if CARD.fullmatch(part)]
|
|
||||||
card = candidates[-1] if candidates else None
|
|
||||||
entry = f"{card} {account}" if card else account
|
|
||||||
if entry not in entries:
|
|
||||||
entries.append(entry)
|
|
||||||
return entries, errors
|
|
||||||
@@ -98,9 +98,109 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_accounts_from_message(text: str) -> List[str]:
|
def parse_accounts_from_message(text: str) -> List[str]:
|
||||||
"""Return all recognized records, preserving card numbers and leading zeroes."""
|
"""
|
||||||
from .account_input import parse_account_records
|
Извлекает все валидные номера счетов из текста сообщения.
|
||||||
return parse_account_records(text or "")[0]
|
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
|
||||||
|
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
|
||||||
|
|
||||||
|
Также обрабатывает многострочный текст из кабинета:
|
||||||
|
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
|
||||||
|
"Viposnova 16-11-2025 22:19:36
|
||||||
|
17-24-66-42-38-31-53
|
||||||
|
0.00 2918"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
accounts = []
|
||||||
|
|
||||||
|
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
current_record = []
|
||||||
|
records = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
|
||||||
|
if stripped.startswith('Viposnova') and current_record:
|
||||||
|
records.append(' '.join(current_record))
|
||||||
|
current_record = [stripped]
|
||||||
|
else:
|
||||||
|
current_record.append(stripped)
|
||||||
|
|
||||||
|
# Добавляем последнюю запись
|
||||||
|
if current_record:
|
||||||
|
records.append(' '.join(current_record))
|
||||||
|
|
||||||
|
# Обрабатываем каждую запись
|
||||||
|
for record in records:
|
||||||
|
parts = record.split()
|
||||||
|
|
||||||
|
# Ищем счет в записи
|
||||||
|
account_number = None
|
||||||
|
account_idx = None
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
|
||||||
|
account_number = format_account_number(part)
|
||||||
|
account_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if not account_number or account_number in accounts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ищем клубную карту (4-значное число после счета)
|
||||||
|
card = None
|
||||||
|
if account_idx is not None:
|
||||||
|
for j in range(account_idx + 1, len(parts)):
|
||||||
|
if re.match(r'^\d{4}$', parts[j]):
|
||||||
|
card = parts[j]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Добавляем результат
|
||||||
|
if card:
|
||||||
|
full_account = f"{card} {account_number}"
|
||||||
|
if full_account not in accounts:
|
||||||
|
accounts.append(full_account)
|
||||||
|
else:
|
||||||
|
accounts.append(account_number)
|
||||||
|
|
||||||
|
# Если построчная обработка ничего не нашла, используем старый метод
|
||||||
|
if not accounts:
|
||||||
|
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
|
||||||
|
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
|
||||||
|
|
||||||
|
# Находим все совпадения с картой и удаляем их из текста
|
||||||
|
text_copy = text
|
||||||
|
for match in re.finditer(pattern_with_card, text):
|
||||||
|
card = match.group(1)
|
||||||
|
account = match.group(2)
|
||||||
|
formatted = format_account_number(account)
|
||||||
|
if formatted:
|
||||||
|
full_account = f"{card} {formatted}"
|
||||||
|
if full_account not in accounts:
|
||||||
|
accounts.append(full_account)
|
||||||
|
# Удаляем это совпадение из копии текста, чтобы не найти повторно
|
||||||
|
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
|
||||||
|
|
||||||
|
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
|
||||||
|
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
|
||||||
|
matches_only = re.findall(pattern_only_account, text_copy)
|
||||||
|
|
||||||
|
for match in matches_only:
|
||||||
|
formatted = format_account_number(match)
|
||||||
|
if formatted and formatted not in accounts:
|
||||||
|
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
|
||||||
|
is_duplicate = any(formatted in acc for acc in accounts)
|
||||||
|
if not is_duplicate:
|
||||||
|
accounts.append(formatted)
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:
|
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:
|
||||||
|
|||||||
@@ -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, WinnerVerification
|
from ..core.models import User, Lottery, Participation, Winner
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
@@ -219,7 +219,6 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -321,7 +320,6 @@ 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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Supervised bulk sends that do not keep the initiating user's FSM locked."""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from weakref import WeakKeyDictionary
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_jobs = WeakKeyDictionary()
|
|
||||||
MAX_JOBS = 2
|
|
||||||
|
|
||||||
|
|
||||||
def jobs():
|
|
||||||
return _jobs.setdefault(asyncio.get_running_loop(), {})
|
|
||||||
|
|
||||||
|
|
||||||
def start_broadcast(actor_id, run, on_error):
|
|
||||||
active = jobs()
|
|
||||||
if actor_id in active or len(active) >= MAX_JOBS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def supervise():
|
|
||||||
try:
|
|
||||||
await run()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Broadcast job failed for actor=%s", actor_id)
|
|
||||||
try:
|
|
||||||
await on_error()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Could not deliver broadcast failure report for actor=%s", actor_id)
|
|
||||||
finally:
|
|
||||||
active.pop(actor_id, None)
|
|
||||||
|
|
||||||
task = asyncio.create_task(supervise(), name=f"broadcast-{actor_id}")
|
|
||||||
active[actor_id] = task
|
|
||||||
# Cancellation can happen before supervise() enters its try/finally.
|
|
||||||
def remove_finished(completed):
|
|
||||||
if active.get(actor_id) is completed:
|
|
||||||
active.pop(actor_id, None)
|
|
||||||
task.add_done_callback(remove_finished)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def shutdown_broadcasts():
|
|
||||||
active = list(jobs().values())
|
|
||||||
for task in active:
|
|
||||||
task.cancel()
|
|
||||||
await asyncio.gather(*active, return_exceptions=True)
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""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()
|
|
||||||
DELIVERY_TIMEOUT = 30.0
|
|
||||||
MAX_RETRY_AFTER = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
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():
|
|
||||||
async with asyncio.timeout(DELIVERY_TIMEOUT):
|
|
||||||
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(**{"request_timeout": 15, **kwargs})
|
|
||||||
except TelegramRetryAfter as error:
|
|
||||||
if attempt or error.retry_after > MAX_RETRY_AFTER:
|
|
||||||
raise
|
|
||||||
await asyncio.sleep(error.retry_after)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""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 "Не удалось выполнить действие. Повторите попытку позже."
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Validation shared by dialog steps and database services."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def club_card(value):
|
|
||||||
value = value.strip() if isinstance(value, str) else ""
|
|
||||||
if not re.fullmatch(r"[0-9]{1,50}", value):
|
|
||||||
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр без букв и разделителей.")
|
|
||||||
return value # Leading zeroes are part of the card number.
|
|
||||||
|
|
||||||
|
|
||||||
def phone_number(value):
|
|
||||||
if value is None or value.strip() == "-":
|
|
||||||
return None
|
|
||||||
value = value.strip()
|
|
||||||
if not value or len(value) > 20 or not re.fullmatch(r"\+?[0-9() -]+", value) or not re.search(r"[0-9]", value):
|
|
||||||
raise ValueError("Введите телефон: цифры, при необходимости +, пробелы, скобки или дефисы; не более 20 символов. Для пропуска отправьте '-'.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def numeric_id(value, *, maximum=2**63 - 1, signed=False):
|
|
||||||
value = str(value).strip()
|
|
||||||
pattern = r"-?[0-9]{1,19}" if signed else r"[0-9]{1,19}"
|
|
||||||
if not re.fullmatch(pattern, value) or not 0 < abs(int(value)) <= maximum:
|
|
||||||
raise ValueError("Некорректный ID: требуется число в допустимом диапазоне.")
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def lottery_field(field, value):
|
|
||||||
if field == "title":
|
|
||||||
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 500:
|
|
||||||
raise ValueError("Название должно содержать от 1 до 500 символов.")
|
|
||||||
return value.strip()
|
|
||||||
if field == "description":
|
|
||||||
if value is None or value == "" or value.strip() == "-":
|
|
||||||
return None
|
|
||||||
if not value.strip() or len(value) > 4096:
|
|
||||||
raise ValueError("Введите описание до 4096 символов или '-' для пропуска.")
|
|
||||||
return value.strip()
|
|
||||||
if field == "prizes":
|
|
||||||
prizes = [line.strip() for line in value.splitlines() if line.strip()] if isinstance(value, str) else value
|
|
||||||
if not isinstance(prizes, list) or not 1 <= len(prizes) <= 100 or any(
|
|
||||||
not isinstance(prize, str) or not 1 <= len(prize.strip()) <= 500 for prize in prizes
|
|
||||||
):
|
|
||||||
raise ValueError("Введите от 1 до 100 призов, каждый с новой строки и не длиннее 500 символов.")
|
|
||||||
return [prize.strip() for prize in prizes]
|
|
||||||
raise ValueError("Неизвестное поле розыгрыша.")
|
|
||||||
@@ -11,44 +11,148 @@ from ..core.models import Winner, User
|
|||||||
from ..core.services import LotteryService
|
from ..core.services import LotteryService
|
||||||
from ..core.registration_services import AccountService, WinnerNotificationService
|
from ..core.registration_services import AccountService, WinnerNotificationService
|
||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
|
from ..core.delivery import delivery_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
if winner.is_notified:
|
|
||||||
continue
|
|
||||||
owner = winner.user
|
|
||||||
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:
|
try:
|
||||||
await send_background(bot, chat_id=chat_id, text=text, reply_markup=keyboard)
|
# Если у победителя есть account_number, ищем владельца
|
||||||
except Exception:
|
if winner.account_number:
|
||||||
logger.exception("Could not notify winner %s", winner_id)
|
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]}"
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
delivery = await delivery_service.send_with_guard(
|
||||||
|
owner.telegram_id,
|
||||||
|
lambda: bot.send_message(
|
||||||
|
owner.telegram_id,
|
||||||
|
message,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delivery.success:
|
||||||
|
# Отмечаем, что уведомление отправлено
|
||||||
|
winner.is_notified = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Уведомление победителю %s не отправлено: %s",
|
||||||
|
owner.telegram_id,
|
||||||
|
delivery.status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
|
||||||
|
|
||||||
|
# Если победитель - обычный пользователь (старая система)
|
||||||
|
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]}"
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
delivery = await delivery_service.send_with_guard(
|
||||||
|
user.telegram_id,
|
||||||
|
lambda: bot.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
message,
|
||||||
|
reply_markup=keyboard
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delivery.success:
|
||||||
|
winner.is_notified = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Уведомление победителю %s не отправлено: %s",
|
||||||
|
user.telegram_id,
|
||||||
|
delivery.status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке уведомления победителю {winner.id}: {e}")
|
||||||
continue
|
continue
|
||||||
async with async_session_maker() as status_session:
|
|
||||||
await status_session.execute(update(Winner).where(Winner.id == winner_id).values(is_notified=True))
|
logger.info(f"Завершена отправка уведомлений для розыгрыша {lottery_id}")
|
||||||
await status_session.commit()
|
|
||||||
sent += 1
|
|
||||||
return sent
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -152,6 +152,7 @@ class AsyncTaskManager:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
# Получаем семафоры
|
# Получаем семафоры
|
||||||
async with self.worker_semaphore:
|
async with self.worker_semaphore:
|
||||||
user_semaphore = self._get_user_semaphore(task.user_id)
|
user_semaphore = self._get_user_semaphore(task.user_id)
|
||||||
@@ -160,8 +161,8 @@ class AsyncTaskManager:
|
|||||||
await self._execute_task(worker_name, task)
|
await self._execute_task(worker_name, task)
|
||||||
else:
|
else:
|
||||||
await self._execute_task(worker_name, task)
|
await self._execute_task(worker_name, task)
|
||||||
|
finally:
|
||||||
# Отмечаем задачу как выполненную
|
# Отмечаем задачу как обработанную даже при ошибке выполнения.
|
||||||
self.task_queue.task_done()
|
self.task_queue.task_done()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Copy user messages without losing Telegram entities, including custom emoji."""
|
|
||||||
import asyncio
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from aiogram.types import Message, MessageEntity
|
|
||||||
|
|
||||||
COPY_TIMEOUT = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
def bounded_copy(func):
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapped(*args, **kwargs):
|
|
||||||
async with asyncio.timeout(COPY_TIMEOUT):
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@bounded_copy
|
|
||||||
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)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Long plain-text form previews keep their action buttons on the last page."""
|
|
||||||
|
|
||||||
|
|
||||||
async def answer_plain_pages(message, text, **kwargs):
|
|
||||||
# A Python character occupies at most two Telegram UTF-16 units.
|
|
||||||
# Plain text needs neither entity splitting nor HTML tag repair.
|
|
||||||
while len(text) > 2000:
|
|
||||||
end = text.rfind("\n", 0, 2001)
|
|
||||||
end = end + 1 if end > 0 else 2000
|
|
||||||
await message.answer(text[:end], parse_mode=None)
|
|
||||||
text = text[end:]
|
|
||||||
return await message.answer(text, parse_mode=None, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
async def edit_plain_pages(message, text, **kwargs):
|
|
||||||
if len(text) <= 2000:
|
|
||||||
return await message.edit_text(text, parse_mode=None, **kwargs)
|
|
||||||
await message.edit_text("Результат операции — в сообщениях ниже.", parse_mode=None)
|
|
||||||
return await answer_plain_pages(message, text, **kwargs)
|
|
||||||
@@ -11,9 +11,18 @@ from ..core.config import ADMIN_IDS
|
|||||||
|
|
||||||
|
|
||||||
async def setup_admin_users():
|
async def setup_admin_users():
|
||||||
"""Configured system admins already have access; never persist it as a second role."""
|
"""Установить права администратора для пользователей из ADMIN_IDS"""
|
||||||
print(f"System administrators are loaded directly from ADMIN_IDS ({len(ADMIN_IDS)} configured).")
|
if not ADMIN_IDS:
|
||||||
print("Use /staff in the bot to appoint ordinary administrators and cashiers.")
|
print("❌ Список ADMIN_IDS пуст")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
for admin_id in ADMIN_IDS:
|
||||||
|
success = await UserService.set_admin(session, admin_id, True)
|
||||||
|
if success:
|
||||||
|
print(f"✅ Права администратора установлены для ID: {admin_id}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Пользователь с ID {admin_id} не найден в базе")
|
||||||
|
|
||||||
|
|
||||||
async def create_sample_lottery():
|
async def create_sample_lottery():
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user