Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 | |||
| 8afe613451 | |||
| 8de9c6e532 | |||
| b69fcbde11 | |||
| 44d6b6b266 | |||
| 4234769e92 | |||
| c2cabbee09 | |||
| 067b1b6716 | |||
| 5b64046137 | |||
| 085a61721a | |||
| 4c2e11e64b | |||
| 7c5f35b1cf | |||
| 561708b777 | |||
| 806a959662 | |||
| 966a599c2c | |||
| 0ed98c39b6 | |||
| 095bc00b33 | |||
| 43762ce8f4 | |||
| 51752713b3 | |||
| b6fc63e33c | |||
| 488d5c99f7 | |||
| 9151882245 | |||
| 02eca28bc0 | |||
| 3f7ca7be3a | |||
| f7abe03dba | |||
| 992a5cb97f | |||
| 3d94e63c3c | |||
| 79569510d2 | |||
| b9352a5bd5 | |||
| 13ebfdb5c4 | |||
| e8a413c3c3 | |||
| aa1d3289e1 | |||
| 94a00ab269 | |||
| b8682adbbf | |||
| cf10eeda53 | |||
| 3cfac7e2dc | |||
| 39e111c91b | |||
| ba42517808 | |||
| 13846d621a | |||
| e612e2f383 | |||
| 1f524ccd80 | |||
| 37bde2985e | |||
| c6a5e0d4be | |||
| 3985053636 | |||
| 4cfb1dd38f | |||
| 117a417ce0 | |||
| a2e0474572 | |||
| e992891691 | |||
| 57cf8687d4 | |||
| 27870bbdcb | |||
| 21d48078ed | |||
| afb4f162d0 | |||
| 96d479780f | |||
| 2e0cd5d54c | |||
| c4374ce483 | |||
| bd1a0d4a4e | |||
| 97f4cc0f7c | |||
| 3ebbb42096 | |||
| 07ae7c2a7f | |||
| 5c3505aec9 | |||
| fffa231b7e | |||
| f8db099d0f | |||
| 9483517258 | |||
| 0c0ab58236 | |||
| 92ec1219fa | |||
| bf72e81d55 | |||
| 5a008e59a2 | |||
| 61b8d586d3 | |||
| df62e2bd96 | |||
| 07e8990ddb | |||
| 40fc4d7267 | |||
| 5ffd3093ea | |||
| 01ac2c7ed0 | |||
| 937a25aafd | |||
| aeee018a53 | |||
| 5cef11f32b | |||
| 467f67907f | |||
| 6d38531f42 | |||
| 2dd057a911 | |||
| 4178e4b024 | |||
| 21bcde26e5 | |||
| 878606d745 | |||
| 45a90876da | |||
| bb4f496b21 | |||
| 47c1de1cc8 | |||
| f69156d7ee | |||
| c9d559e3f2 | |||
| e28a48853d | |||
| b13da1f2e8 | |||
| ba1bf677d6 | |||
| ccdff05dca | |||
| 03875b593e | |||
| 06224d798d | |||
| 966f436723 | |||
| 4941fe9469 | |||
| fe56078481 | |||
| 39742499b8 | |||
| d8207fa1f0 | |||
| 7eb302aab0 | |||
| 8cb5da4f74 | |||
| cad786f6ee | |||
| 0adc7145a1 | |||
| d35f54a4db | |||
| 000d670869 | |||
| 0c9b69deb0 | |||
| 63e31e84fc | |||
| 1bd301f21a | |||
| 3dbaf99733 | |||
| 4380611bee | |||
| 5aca466d72 | |||
| 86970f0398 | |||
| 733be09658 | |||
| 5e75210c8b | |||
| cb0ccc9c77 | |||
| 078eebfbb1 | |||
| 4049e0d9ff | |||
| 7bf7b942ba | |||
| 4f1c14fda0 | |||
| 7bd6ae3c26 | |||
| caeafc7abd | |||
| 4d249a3bc9 | |||
| 1f26b522b4 | |||
| d1ed6c1b18 | |||
| d4d89ec20d | |||
| 6a2fd5a7de | |||
| f41c5a10b6 | |||
| 3e873a05a4 | |||
| c22f41cbf2 | |||
| f3851b9ecc | |||
| 9a258744f2 | |||
| 8780ae9407 | |||
| 3f886df44f | |||
| e3c6d4a5a1 | |||
| c3215a1e54 | |||
| fdd5a8aa6e | |||
| c1792f487a | |||
| f27f0f8bca | |||
| c70ddfe157 | |||
| 554d776b77 | |||
| 34ac9eb6ed | |||
| 6cc48d2872 | |||
| 9fc31c25b2 | |||
| 73585ebc82 | |||
| e9d3e9a2be | |||
| 3e6e2f577c | |||
| 39c644b505 | |||
| 62500a6369 | |||
| 9e16b56d9a | |||
| 9dfadcda72 | |||
| e2afdc28f3 | |||
| 86bd24edd4 | |||
| 2bda556c4b | |||
| 60724a0354 | |||
| 250edec20e | |||
| 6b1e78f990 | |||
| 56f784c8bf | |||
| ce822ead2b | |||
| e606c1d4d5 | |||
| c204194b8b | |||
| 5a878239f3 | |||
| 08aa8dabfd | |||
| 20ed6071e2 | |||
| 8d34a4b3d2 | |||
| 94a9528397 | |||
| 403052a840 | |||
| bb8e5bb6ca | |||
| 11ee8764a6 | |||
| f9be0e6315 | |||
| 9e56c56528 | |||
| 85cf96b813 | |||
| c16eee4ef2 | |||
| b6bd2625c2 | |||
| 020343cdf8 | |||
| c07fffd809 | |||
| afaeeaf7f1 | |||
| e946cc7354 | |||
| bf6a966668 | |||
| 7f12fa7003 | |||
| 5f6ef5993c | |||
| f1ac67e511 | |||
| 48f9f606aa | |||
| 7d9d1b0a6f | |||
| afadf7160c | |||
| b546fbd1cc | |||
| b8f1785783 | |||
| 7ee8c8ff4d | |||
| 551112d2d9 | |||
| e9dc9630b2 | |||
| b068c1fb12 | |||
| 9f66e176f7 | |||
| c66bad99f0 | |||
| f6a29760a9 | |||
| d7e1b8fd5d | |||
| 54e9175bdf | |||
| f581b10e19 | |||
| 1ae6ea18b7 | |||
| bea6c02d89 | |||
| 153083d791 | |||
| 80538b39ac | |||
| 205f9e8e3c | |||
| 97ccfd3af0 | |||
| 4f77ece187 | |||
| 56a69fa1ba | |||
| 5c94bda60a | |||
| b8d0e6eefb | |||
| b7af25644a | |||
| e312767247 | |||
| 1dfa243736 | |||
| e0d667df28 |
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
release:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
|
||||
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
# Collect commits by category
|
||||
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
|
||||
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
|
||||
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
|
||||
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
|
||||
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
|
||||
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
|
||||
|
||||
# Collect unique contributors
|
||||
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
|
||||
|
||||
# Stats
|
||||
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
|
||||
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
|
||||
|
||||
# Format function
|
||||
format_section() {
|
||||
local commits="$1"
|
||||
if [ -n "$commits" ]; then
|
||||
echo "$commits" | while IFS='|' read -r msg author hash; do
|
||||
# Clean conventional commit prefix
|
||||
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
|
||||
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Build changelog
|
||||
{
|
||||
echo "changelog<<CHANGELOG_EOF"
|
||||
|
||||
if [ -n "$FEATURES" ]; then
|
||||
echo "### New Features"
|
||||
echo ""
|
||||
format_section "$FEATURES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
echo "### Bug Fixes"
|
||||
echo ""
|
||||
format_section "$FIXES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$PERF" ]; then
|
||||
echo "### Performance & Refactoring"
|
||||
echo ""
|
||||
format_section "$PERF"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "### Documentation & Style"
|
||||
echo ""
|
||||
format_section "$DOCS"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$CHORE" ]; then
|
||||
echo "### Maintenance"
|
||||
echo ""
|
||||
format_section "$CHORE"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
echo "### Other Changes"
|
||||
echo ""
|
||||
format_section "$OTHER"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "### Contributors"
|
||||
echo ""
|
||||
if [ -n "$CONTRIBUTORS" ]; then
|
||||
echo "$CONTRIBUTORS" | while read -r name; do
|
||||
echo "- @${name}"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "### Stats"
|
||||
echo ""
|
||||
echo "- **Commits:** ${TOTAL_COMMITS}"
|
||||
echo "- **Changes:** ${FILES_CHANGED}"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
|
||||
fi
|
||||
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
---
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Docker Hub
|
||||
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# Or with Make
|
||||
make reload
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
|
||||
generate_release_notes: false
|
||||
+85
-32
@@ -1,49 +1,102 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
# ========== WHITELIST: разрешённые файлы ==========
|
||||
|
||||
# Конфигурация проекта
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!.gitignore
|
||||
!.python-version
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docker-compose.yml
|
||||
!docker-compose.local.yml
|
||||
!Makefile
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!.python-version
|
||||
!docs/
|
||||
!docs/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
# Документация
|
||||
!README.md
|
||||
!LICENSE
|
||||
!CONTRIBUTING.md
|
||||
!SECURITY.md
|
||||
|
||||
# Скрипты
|
||||
!install_bot.sh
|
||||
!main.py
|
||||
|
||||
# Статические файлы
|
||||
!vpn_logo.png
|
||||
|
||||
# ========== WHITELIST: разрешённые папки ==========
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Разрешаем .github/ (workflows, pre-commit и т.д.)
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!docs/
|
||||
!docs/**
|
||||
!assets/
|
||||
!assets/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# Разрешаем Makefile
|
||||
!Makefile
|
||||
# ========== BLACKLIST: игнорируемые внутри папок ==========
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
# Python
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build/dist
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Testing/coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local overrides (не коммитить!)
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Runtime data
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.7.2"
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
|
||||
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
|
||||
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
|
||||
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
|
||||
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
|
||||
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
|
||||
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
|
||||
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.2.0"
|
||||
ARG VERSION="v3.7.2" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
|
||||
|
||||
---
|
||||
|
||||
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
|
||||
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
|
||||
|
||||
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.handlers import (
|
||||
from app.handlers.admin import (
|
||||
backup as admin_backup,
|
||||
blacklist as admin_blacklist,
|
||||
blocked_users as admin_blocked_users,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
bulk_ban as admin_bulk_ban,
|
||||
campaigns as admin_campaigns,
|
||||
@@ -60,6 +61,7 @@ from app.handlers.admin import (
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
@@ -118,6 +120,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
@@ -189,6 +195,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_tariffs.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
if is_blacklisted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'blacklisted',
|
||||
'message': reason or 'Доступ запрещен',
|
||||
},
|
||||
)
|
||||
|
||||
# Check maintenance mode (allow admins to pass)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
|
||||
@@ -17,6 +17,7 @@ from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -26,6 +27,7 @@ from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -45,6 +47,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
@@ -83,6 +86,7 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -575,9 +575,24 @@ async def broadcast_offer(
|
||||
notifications_failed = 0
|
||||
|
||||
if payload.send_notification and offers_to_notify:
|
||||
# Render placeholders in custom message text
|
||||
rendered_message_text = payload.message_text
|
||||
if rendered_message_text:
|
||||
extra = payload.extra_data or {}
|
||||
try:
|
||||
rendered_message_text = rendered_message_text.format(
|
||||
discount_percent=payload.discount_percent,
|
||||
valid_hours=payload.valid_hours,
|
||||
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
|
||||
test_duration_hours=extra.get('test_duration_hours') or 0,
|
||||
server_name=extra.get('server_name', ''),
|
||||
)
|
||||
except (KeyError, ValueError, IndexError):
|
||||
logger.warning('Failed to render promo message placeholders')
|
||||
|
||||
notifications_sent, notifications_failed = await _send_promo_notifications(
|
||||
offers_to_notify=offers_to_notify,
|
||||
message_text=payload.message_text,
|
||||
message_text=rendered_message_text,
|
||||
button_text=payload.button_text,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
|
||||
@@ -472,6 +472,61 @@ async def delete_promocode_endpoint(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DeactivateDiscountResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
# ============== PromoGroup Endpoints ==============
|
||||
|
||||
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.database.crud.tariff import (
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
@@ -29,6 +30,7 @@ from ..schemas.tariffs import (
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
@@ -157,6 +159,21 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
@@ -371,7 +388,7 @@ async def update_existing_tariff(
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
|
||||
|
||||
+115
-110
@@ -220,12 +220,123 @@ async def get_ticket_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=AdminTicketListResponse)
|
||||
async def get_all_tickets(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -245,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
@@ -490,113 +605,3 @@ async def update_ticket_priority(
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
|
||||
if search_lower:
|
||||
if search_lower not in (full_name or '').lower() and search_lower not in (username or '').lower():
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Devices': item.device_limit,
|
||||
}
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
extend_subscription,
|
||||
)
|
||||
@@ -38,9 +39,17 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
DisableUserRequest,
|
||||
DisableUserResponse,
|
||||
FullDeleteUserRequest,
|
||||
FullDeleteUserResponse,
|
||||
PanelSyncStatusResponse,
|
||||
PanelUserInfo,
|
||||
PeriodPriceInfo,
|
||||
ResetSubscriptionRequest,
|
||||
ResetSubscriptionResponse,
|
||||
ResetTrialRequest,
|
||||
ResetTrialResponse,
|
||||
SortByEnum,
|
||||
SyncFromPanelRequest,
|
||||
SyncFromPanelResponse,
|
||||
@@ -60,6 +69,9 @@ from ..schemas.users import (
|
||||
UserAvailableTariffsResponse,
|
||||
UserDetailResponse,
|
||||
UserListItem,
|
||||
UserNodeUsageItem,
|
||||
UserNodeUsageResponse,
|
||||
UserPanelInfoResponse,
|
||||
UserPromoGroupInfo,
|
||||
UserReferralInfo,
|
||||
UsersListResponse,
|
||||
@@ -517,6 +529,14 @@ async def get_user_detail(
|
||||
for t in transactions
|
||||
]
|
||||
|
||||
# Get campaign info
|
||||
campaign_name = None
|
||||
campaign_id = None
|
||||
campaign_reg = await get_campaign_registration_by_user(db, user.id)
|
||||
if campaign_reg and campaign_reg.campaign:
|
||||
campaign_name = campaign_reg.campaign.name
|
||||
campaign_id = campaign_reg.campaign.id
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -542,6 +562,8 @@ async def get_user_detail(
|
||||
used_promocodes=user.used_promocodes,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
|
||||
campaign_name=campaign_name,
|
||||
campaign_id=campaign_id,
|
||||
restriction_topup=user.restriction_topup,
|
||||
restriction_subscription=user.restriction_subscription,
|
||||
restriction_reason=user.restriction_reason,
|
||||
@@ -569,6 +591,156 @@ async def get_user_by_telegram(
|
||||
return await get_user_detail(user.id, admin, db)
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
|
||||
async def get_user_panel_info(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured or not user.telegram_id:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if not panel_users:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
|
||||
last_node_name = None
|
||||
last_node_uuid = None
|
||||
if panel_user.user_traffic and panel_user.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid = panel_user.user_traffic.last_connected_node_uuid
|
||||
try:
|
||||
accessible = await api.get_user_accessible_nodes(panel_user.uuid)
|
||||
for node in accessible:
|
||||
if node.uuid == last_node_uuid:
|
||||
last_node_name = node.node_name
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(f'Failed to resolve node name for user {user_id}')
|
||||
|
||||
return UserPanelInfoResponse(
|
||||
found=True,
|
||||
trojan_password=panel_user.trojan_password,
|
||||
vless_uuid=panel_user.vless_uuid,
|
||||
ss_password=panel_user.ss_password,
|
||||
subscription_url=panel_user.subscription_url,
|
||||
happ_link=panel_user.happ_link,
|
||||
used_traffic_bytes=panel_user.used_traffic_bytes,
|
||||
lifetime_used_traffic_bytes=panel_user.lifetime_used_traffic_bytes,
|
||||
traffic_limit_bytes=panel_user.traffic_limit_bytes,
|
||||
first_connected_at=panel_user.first_connected_at,
|
||||
online_at=panel_user.online_at,
|
||||
last_connected_node_uuid=last_node_uuid,
|
||||
last_connected_node_name=last_node_name,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting panel info for user {user_id}: {e}')
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
|
||||
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
|
||||
async def get_user_node_usage(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
start_str = start_date.strftime('%Y-%m-%d')
|
||||
end_str = end_date.strftime('%Y-%m-%d')
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Get user's accessible nodes (1 API call)
|
||||
accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid)
|
||||
|
||||
# Get user bandwidth stats (1 API call)
|
||||
# Response: {categories: [dates], series: [{uuid, name, countryCode, total, data: [daily]}, ...]}
|
||||
stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str)
|
||||
|
||||
categories: list[str] = []
|
||||
series_map: dict[str, dict] = {}
|
||||
if isinstance(stats, dict):
|
||||
categories = stats.get('categories', [])
|
||||
for s in stats.get('series', []):
|
||||
series_map[s['uuid']] = {
|
||||
'name': s.get('name', ''),
|
||||
'country_code': s.get('countryCode', ''),
|
||||
'total': int(s.get('total', 0)),
|
||||
'daily': [int(v) for v in s.get('data', [])],
|
||||
}
|
||||
|
||||
# Build items: accessible nodes + any extra from stats
|
||||
items = []
|
||||
seen_uuids: set[str] = set()
|
||||
for node in accessible_nodes:
|
||||
seen_uuids.add(node.uuid)
|
||||
sr = series_map.get(node.uuid)
|
||||
items.append(
|
||||
UserNodeUsageItem(
|
||||
node_uuid=node.uuid,
|
||||
node_name=sr['name'] if sr else node.node_name,
|
||||
country_code=sr['country_code'] if sr else node.country_code,
|
||||
total_bytes=sr['total'] if sr else 0,
|
||||
daily_bytes=sr['daily'] if sr else [],
|
||||
)
|
||||
)
|
||||
for nid, sr in series_map.items():
|
||||
if nid not in seen_uuids:
|
||||
items.append(
|
||||
UserNodeUsageItem(
|
||||
node_uuid=nid,
|
||||
node_name=sr['name'],
|
||||
country_code=sr['country_code'],
|
||||
total_bytes=sr['total'],
|
||||
daily_bytes=sr['daily'],
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: x.total_bytes, reverse=True)
|
||||
return UserNodeUsageResponse(items=items, categories=categories)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting node usage for user {user_id}: {e}')
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
|
||||
# === Balance Management ===
|
||||
|
||||
|
||||
@@ -1195,6 +1367,245 @@ async def delete_user(
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{user_id}/full', response_model=FullDeleteUserResponse)
|
||||
async def full_delete_user(
|
||||
user_id: int,
|
||||
request: FullDeleteUserRequest = FullDeleteUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Full user deletion - removes from bot database AND Remnawave panel.
|
||||
|
||||
Uses UserService.delete_user_account() which handles:
|
||||
- Deleting/disabling user in Remnawave panel
|
||||
- Removing all related records (payments, transactions, etc.)
|
||||
- Removing user from database
|
||||
"""
|
||||
from app.services.user_service import UserService
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
panel_error: str | None = None
|
||||
deleted_from_panel = False
|
||||
|
||||
# UserService.delete_user_account handles both bot DB and Remnawave panel
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, admin.id)
|
||||
|
||||
if success:
|
||||
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
|
||||
|
||||
return FullDeleteUserResponse(
|
||||
success=success,
|
||||
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
|
||||
deleted_from_bot=success,
|
||||
deleted_from_panel=deleted_from_panel,
|
||||
panel_error=panel_error,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{user_id}/reset-trial', response_model=ResetTrialResponse)
|
||||
async def reset_user_trial(
|
||||
user_id: int,
|
||||
request: ResetTrialRequest = ResetTrialRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Reset user trial - allows user to activate trial again.
|
||||
|
||||
Actions:
|
||||
- Delete current subscription if exists
|
||||
- Reset has_used_trial flag to False
|
||||
- User can now activate a new trial
|
||||
"""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
subscription_deleted = False
|
||||
|
||||
# Delete subscription if exists
|
||||
if user.subscription:
|
||||
# Deactivate in Remnawave panel first
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
|
||||
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
# Reset trial flag
|
||||
user.has_used_trial = False
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
|
||||
|
||||
return ResetTrialResponse(
|
||||
success=True,
|
||||
message='Trial reset successfully. User can now activate a new trial.',
|
||||
subscription_deleted=subscription_deleted,
|
||||
has_used_trial_reset=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{user_id}/reset-subscription', response_model=ResetSubscriptionResponse)
|
||||
async def reset_user_subscription(
|
||||
user_id: int,
|
||||
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Reset user subscription - removes/deactivates subscription.
|
||||
|
||||
Actions:
|
||||
- Delete subscription from bot database
|
||||
- Optionally deactivate in Remnawave panel
|
||||
- User will have no active subscription
|
||||
"""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
subscription_deleted = False
|
||||
panel_deactivated = False
|
||||
panel_error: str | None = None
|
||||
|
||||
if not user.subscription:
|
||||
return ResetSubscriptionResponse(
|
||||
success=True,
|
||||
message='User has no subscription to reset',
|
||||
subscription_deleted=False,
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
# Deactivate in Remnawave panel if requested
|
||||
if request.deactivate_in_panel and user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
if panel_deactivated:
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
|
||||
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
|
||||
|
||||
return ResetSubscriptionResponse(
|
||||
success=True,
|
||||
message='Subscription reset successfully',
|
||||
subscription_deleted=subscription_deleted,
|
||||
panel_deactivated=panel_deactivated,
|
||||
panel_error=panel_error,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{user_id}/disable', response_model=DisableUserResponse)
|
||||
async def disable_user(
|
||||
user_id: int,
|
||||
request: DisableUserRequest = DisableUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Disable user - deactivates subscription and blocks access.
|
||||
|
||||
Actions:
|
||||
- Deactivate subscription in bot database
|
||||
- Deactivate in Remnawave panel
|
||||
- Block user account
|
||||
"""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
subscription_deactivated = False
|
||||
panel_deactivated = False
|
||||
panel_error: str | None = None
|
||||
|
||||
# Deactivate subscription in panel
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
if panel_deactivated:
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
logger.warning(f'Failed to disable Remnawave user: {e}')
|
||||
|
||||
# Deactivate subscription in bot database
|
||||
if user.subscription:
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
|
||||
await deactivate_subscription(db, user.subscription)
|
||||
subscription_deactivated = True
|
||||
logger.info(f'Deactivated subscription for user {user_id}')
|
||||
|
||||
# Block user account
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
|
||||
|
||||
return DisableUserResponse(
|
||||
success=True,
|
||||
message='User disabled successfully',
|
||||
subscription_deactivated=subscription_deactivated,
|
||||
panel_deactivated=panel_deactivated,
|
||||
user_blocked=True,
|
||||
panel_error=panel_error,
|
||||
)
|
||||
|
||||
|
||||
# === User Referrals ===
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
@@ -385,6 +386,13 @@ async def register_email(
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
@@ -478,6 +486,13 @@ async def register_email_standalone(
|
||||
)
|
||||
logger.info(f'Test email registration: {request.email}')
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing.scalar_one_or_none():
|
||||
@@ -971,6 +986,13 @@ async def request_email_change(
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -341,13 +340,11 @@ async def create_topup(
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
yookassa_service = YooKassaService()
|
||||
payment_service = PaymentService()
|
||||
yookassa_metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'user_username': user.username or '',
|
||||
'amount_kopeks': str(request.amount_kopeks),
|
||||
'type': 'balance_topup',
|
||||
'purpose': 'balance_topup',
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
@@ -358,25 +355,25 @@ async def create_topup(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
# Create SBP payment with QR code
|
||||
result = await yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
else:
|
||||
# Default: card payment
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
|
||||
if result and not result.get('error'):
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('id')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via %s for existing user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
@@ -33,6 +33,15 @@ class PromocodeActivateResponse(BaseModel):
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
class PromocodeDeactivateResponse(BaseModel):
|
||||
"""Response after deactivating a discount promo code."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
@@ -62,6 +71,8 @@ async def activate_promocode(
|
||||
'expired': 'Promo code has expired',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'user_not_found': 'User not found',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
@@ -73,3 +84,41 @@ async def activate_promocode(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
|
||||
async def deactivate_discount_promocode(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromocodeDeactivateResponse:
|
||||
"""Deactivate the currently active discount promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
admin_initiated=False,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return PromocodeDeactivateResponse(
|
||||
success=True,
|
||||
message='Discount promo code deactivated successfully',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'No active discount promo code found',
|
||||
'discount_already_expired': 'Discount has already expired',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
+930
-150
File diff suppressed because it is too large
Load Diff
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
"""Request to reorder tariffs."""
|
||||
|
||||
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
|
||||
|
||||
|
||||
class TariffToggleResponse(BaseModel):
|
||||
"""Response after toggling tariff."""
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Schemas for admin traffic usage."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrafficNodeInfo(BaseModel):
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
items: list[UserTrafficItem]
|
||||
nodes: list[TrafficNodeInfo]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
period_days: int
|
||||
available_tariffs: list[str]
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
tariffs: str | None = None
|
||||
statuses: str | None = None
|
||||
nodes: str | None = None
|
||||
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
|
||||
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
|
||||
|
||||
|
||||
class ExportCsvResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -189,9 +189,58 @@ class UserDetailResponse(BaseModel):
|
||||
promo_offer_discount_source: str | None = None
|
||||
promo_offer_discount_expires_at: datetime | None = None
|
||||
|
||||
# Campaign
|
||||
campaign_name: str | None = None
|
||||
campaign_id: int | None = None
|
||||
|
||||
# Recent transactions
|
||||
recent_transactions: list[UserTransactionItem] = []
|
||||
|
||||
# Remnawave UUID
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
class UserPanelInfoResponse(BaseModel):
|
||||
"""Panel info for user from Remnawave."""
|
||||
|
||||
found: bool = False
|
||||
trojan_password: str | None = None
|
||||
vless_uuid: str | None = None
|
||||
ss_password: str | None = None
|
||||
subscription_url: str | None = None
|
||||
happ_link: str | None = None
|
||||
used_traffic_bytes: int = 0
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
traffic_limit_bytes: int = 0
|
||||
first_connected_at: datetime | None = None
|
||||
online_at: datetime | None = None
|
||||
last_connected_node_uuid: str | None = None
|
||||
last_connected_node_name: str | None = None
|
||||
|
||||
|
||||
# === Node Usage ===
|
||||
|
||||
|
||||
class UserNodeUsageItem(BaseModel):
|
||||
"""Per-node traffic usage item."""
|
||||
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str = ''
|
||||
total_bytes: int
|
||||
daily_bytes: list[int] = []
|
||||
|
||||
|
||||
class UserNodeUsageResponse(BaseModel):
|
||||
"""Node usage response with 30-day daily breakdown."""
|
||||
|
||||
items: list[UserNodeUsageItem]
|
||||
categories: list[str] = []
|
||||
period_days: int = 30
|
||||
|
||||
|
||||
# === User Actions ===
|
||||
|
||||
@@ -498,3 +547,72 @@ class PanelSyncStatusResponse(BaseModel):
|
||||
# Differences
|
||||
has_differences: bool = False
|
||||
differences: list[str] = []
|
||||
|
||||
|
||||
# === Admin User Management Actions ===
|
||||
|
||||
|
||||
class FullDeleteUserRequest(BaseModel):
|
||||
"""Request for full user deletion (bot + panel)."""
|
||||
|
||||
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
||||
|
||||
|
||||
class FullDeleteUserResponse(BaseModel):
|
||||
"""Response after full user deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_from_bot: bool = False
|
||||
deleted_from_panel: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class ResetTrialRequest(BaseModel):
|
||||
"""Request to reset user trial."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
|
||||
|
||||
|
||||
class ResetTrialResponse(BaseModel):
|
||||
"""Response after trial reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
has_used_trial_reset: bool = False
|
||||
|
||||
|
||||
class ResetSubscriptionRequest(BaseModel):
|
||||
"""Request to reset user subscription."""
|
||||
|
||||
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
|
||||
|
||||
|
||||
class ResetSubscriptionResponse(BaseModel):
|
||||
"""Response after subscription reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
panel_deactivated: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class DisableUserRequest(BaseModel):
|
||||
"""Request to disable user."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
|
||||
|
||||
|
||||
class DisableUserResponse(BaseModel):
|
||||
"""Response after user disable."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deactivated: bool = False
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
+68
-2
@@ -112,6 +112,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: str | None = None
|
||||
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
@@ -236,6 +237,8 @@ class Settings(BaseSettings):
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -695,6 +698,23 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
|
||||
OAUTH_GOOGLE_ENABLED: bool = False
|
||||
|
||||
OAUTH_YANDEX_CLIENT_ID: str = ''
|
||||
OAUTH_YANDEX_CLIENT_SECRET: str = ''
|
||||
OAUTH_YANDEX_ENABLED: bool = False
|
||||
|
||||
OAUTH_DISCORD_CLIENT_ID: str = ''
|
||||
OAUTH_DISCORD_CLIENT_SECRET: str = ''
|
||||
OAUTH_DISCORD_ENABLED: bool = False
|
||||
|
||||
OAUTH_VK_CLIENT_ID: str = ''
|
||||
OAUTH_VK_CLIENT_SECRET: str = ''
|
||||
OAUTH_VK_ENABLED: bool = False
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_PORT: int = 587
|
||||
@@ -1031,8 +1051,9 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
@@ -1309,6 +1330,17 @@ class Settings(BaseSettings):
|
||||
def get_trial_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
|
||||
|
||||
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
|
||||
disabled_for = self.TRIAL_DISABLED_FOR
|
||||
if disabled_for == 'all':
|
||||
return True
|
||||
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
|
||||
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
|
||||
return True
|
||||
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
@@ -2515,6 +2547,40 @@ class Settings(BaseSettings):
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
# OAuth helpers
|
||||
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
|
||||
"""Return config for all OAuth providers (enabled or not)."""
|
||||
return {
|
||||
'google': {
|
||||
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_GOOGLE_ENABLED,
|
||||
'display_name': 'Google',
|
||||
},
|
||||
'yandex': {
|
||||
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_YANDEX_ENABLED,
|
||||
'display_name': 'Yandex',
|
||||
},
|
||||
'discord': {
|
||||
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_DISCORD_ENABLED,
|
||||
'display_name': 'Discord',
|
||||
},
|
||||
'vk': {
|
||||
'client_id': self.OAUTH_VK_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_VK_ENABLED,
|
||||
'display_name': 'VK',
|
||||
},
|
||||
}
|
||||
|
||||
def get_enabled_oauth_provider_names(self) -> list[str]:
|
||||
"""Return list of enabled OAuth provider names."""
|
||||
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
|
||||
|
||||
# Ban System helpers
|
||||
def is_ban_system_enabled(self) -> bool:
|
||||
return bool(self.BAN_SYSTEM_ENABLED)
|
||||
|
||||
@@ -192,6 +192,36 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def get_active_discount_promocode_for_user(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> tuple[PromoCode | None, PromoCodeUse | None]:
|
||||
"""
|
||||
Находит активный промокод на скидку, который сейчас действует у пользователя.
|
||||
Ищет по source-полю пользователя (формат 'promocode:CODE').
|
||||
|
||||
Returns:
|
||||
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
|
||||
"""
|
||||
from app.database.models import User as UserModel
|
||||
|
||||
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
if not source or not source.startswith('promocode:'):
|
||||
return None, None
|
||||
|
||||
code = source.split(':', 1)[1]
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return None, None
|
||||
|
||||
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
|
||||
return promocode, use
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
|
||||
|
||||
@@ -258,6 +258,10 @@ async def replace_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
||||
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
if update_server_counters:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -710,7 +714,10 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
@@ -723,6 +730,11 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
|
||||
ready_for_autopay = []
|
||||
for subscription in all_autopay_subscriptions:
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay на них не распространяется
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
continue
|
||||
|
||||
days_until_expiry = (subscription.end_date - current_time).days
|
||||
|
||||
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
|
||||
|
||||
+28
-25
@@ -233,6 +233,8 @@ async def create_tariff(
|
||||
if promo_group_ids:
|
||||
promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)))
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
# Refresh чтобы избежать lazy load в async контексте
|
||||
await db.refresh(tariff, ['allowed_promo_groups'])
|
||||
tariff.allowed_promo_groups = list(promo_groups)
|
||||
|
||||
await db.commit()
|
||||
@@ -484,16 +486,14 @@ async def reorder_tariffs(
|
||||
for order, tariff_id in enumerate(tariff_order):
|
||||
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Изменен порядок тарифов: %s', tariff_order)
|
||||
|
||||
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
"""
|
||||
Синхронизирует дефолтный тариф из конфига (.env) в БД.
|
||||
Создаёт тариф "Стандартный" если в БД нет тарифов.
|
||||
Обновляет цены существующего тарифа если он есть.
|
||||
Создаёт тариф "Стандартный" только если в БД нет тарифов.
|
||||
Существующий тариф НЕ перезаписывается — админ управляет им через кабинет.
|
||||
|
||||
Returns:
|
||||
Tariff или None если не требуется синхронизация
|
||||
@@ -519,13 +519,11 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
existing_tariff = result.scalar_one_or_none()
|
||||
|
||||
if existing_tariff:
|
||||
# Обновляем цены существующего тарифа
|
||||
existing_tariff.period_prices = period_prices
|
||||
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
await db.commit()
|
||||
await db.refresh(existing_tariff)
|
||||
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
|
||||
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
|
||||
# Админ управляет тарифом через кабинет, синхронизация не нужна.
|
||||
logger.info(
|
||||
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
|
||||
)
|
||||
return existing_tariff
|
||||
|
||||
if tariff_count == 0:
|
||||
@@ -571,21 +569,26 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
)
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
|
||||
else:
|
||||
if not tariff:
|
||||
logger.info('Активные тарифы не найдены, используются цены из .env')
|
||||
return
|
||||
|
||||
if not tariff.period_prices:
|
||||
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
|
||||
return
|
||||
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка загрузки периодов из БД: %s', e)
|
||||
|
||||
+102
-4
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -407,16 +407,17 @@ async def add_user_balance(
|
||||
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
try:
|
||||
from app.database.crud.subscription import resume_daily_subscription
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
subscription = user.subscription
|
||||
# Загружаем подписку явно, чтобы избежать lazy loading
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Проверяем что это суточный тариф
|
||||
is_daily = getattr(subscription, 'is_daily_tariff', False)
|
||||
if is_daily and subscription.tariff_id:
|
||||
# Загружаем тариф явно, чтобы избежать lazy loading
|
||||
# Загружаем тариф явно
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
@@ -1234,3 +1235,100 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Email change cancelled for user {user.id}')
|
||||
|
||||
|
||||
# --- OAuth provider functions ---
|
||||
|
||||
_OAUTH_PROVIDER_COLUMNS = {
|
||||
'google': 'google_id',
|
||||
'yandex': 'yandex_id',
|
||||
'discord': 'discord_id',
|
||||
'vk': 'vk_id',
|
||||
}
|
||||
|
||||
|
||||
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
|
||||
"""Find a user by OAuth provider ID."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return None
|
||||
column = getattr(User, column_name)
|
||||
# VK uses BigInteger, so convert
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
result = await db.execute(select(User).where(column == value))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
|
||||
"""Link an OAuth provider ID to an existing user."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
setattr(user, column_name, value)
|
||||
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}')
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
email: str | None = None,
|
||||
email_verified: bool = False,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
|
||||
user = User(
|
||||
telegram_id=None,
|
||||
auth_type=provider,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
password_hash=None,
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if column_name:
|
||||
setattr(user, column_name, provider_value)
|
||||
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
|
||||
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
await event_emitter.emit(
|
||||
'user.created',
|
||||
{
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'auth_type': provider,
|
||||
'first_name': user.first_name,
|
||||
'referral_code': user.referral_code,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event: %s', error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
|
||||
payment_method_type: str | None = None,
|
||||
yookassa_created_at: datetime | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> YooKassaPayment:
|
||||
) -> YooKassaPayment | None:
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
@@ -40,7 +41,17 @@ async def create_yookassa_payment(
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
|
||||
@@ -995,6 +995,11 @@ class User(Base):
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
email_change_expires = Column(DateTime, nullable=True) # Code expiration
|
||||
# OAuth provider IDs
|
||||
google_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
yandex_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
@@ -1055,6 +1060,11 @@ class User(Base):
|
||||
"""Пользователь зарегистрирован через email (без Telegram)."""
|
||||
return self.auth_type == 'email' and self.telegram_id is None
|
||||
|
||||
@property
|
||||
def is_web_user(self) -> bool:
|
||||
"""Пользователь без Telegram (email, OAuth и т.д.)."""
|
||||
return self.telegram_id is None
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
|
||||
@@ -5094,6 +5094,58 @@ async def add_transaction_receipt_columns() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_oauth_provider_columns() -> bool:
|
||||
"""Добавить колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) в users."""
|
||||
try:
|
||||
google_exists = await check_column_exists('users', 'google_id')
|
||||
yandex_exists = await check_column_exists('users', 'yandex_id')
|
||||
discord_exists = await check_column_exists('users', 'discord_id')
|
||||
vk_exists = await check_column_exists('users', 'vk_id')
|
||||
|
||||
if google_exists and yandex_exists and discord_exists and vk_exists:
|
||||
logger.info('Колонки OAuth провайдеров уже существуют в users')
|
||||
return True
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if not google_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN google_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка google_id в users')
|
||||
|
||||
if not yandex_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN yandex_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка yandex_id в users')
|
||||
|
||||
if not discord_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN discord_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка discord_id в users')
|
||||
|
||||
if not vk_exists:
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id BIGINT'))
|
||||
else:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id INTEGER'))
|
||||
logger.info('✅ Добавлена колонка vk_id в users')
|
||||
|
||||
# Создаём уникальные индексы
|
||||
for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
if db_type in ('postgresql', 'sqlite'):
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})'))
|
||||
else:
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})'))
|
||||
except Exception as idx_error:
|
||||
logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def create_withdrawal_requests_table() -> bool:
|
||||
"""Создаёт таблицу для заявок на вывод реферального баланса."""
|
||||
try:
|
||||
@@ -7045,6 +7097,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с миграцией transaction_id_cp')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК OAUTH ПРОВАЙДЕРОВ ===')
|
||||
oauth_columns_ready = await add_oauth_provider_columns()
|
||||
if oauth_columns_ready:
|
||||
logger.info('✅ Колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) готовы')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
@@ -7157,6 +7216,10 @@ async def check_migration_status():
|
||||
'campaign_tariff_duration_days_column': False,
|
||||
'campaign_registration_tariff_id_column': False,
|
||||
'campaign_registration_tariff_duration_days_column': False,
|
||||
'users_google_id_column': False,
|
||||
'users_yandex_id_column': False,
|
||||
'users_discord_id_column': False,
|
||||
'users_vk_id_column': False,
|
||||
}
|
||||
|
||||
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -7288,6 +7351,12 @@ async def check_migration_status():
|
||||
'transactions', 'receipt_created_at'
|
||||
)
|
||||
|
||||
# Колонки OAuth провайдеров в users
|
||||
status['users_google_id_column'] = await check_column_exists('users', 'google_id')
|
||||
status['users_yandex_id_column'] = await check_column_exists('users', 'yandex_id')
|
||||
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
|
||||
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(
|
||||
text("""
|
||||
@@ -7358,6 +7427,10 @@ async def check_migration_status():
|
||||
'subscription_temporary_access_table': 'Таблица subscription_temporary_access',
|
||||
'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions',
|
||||
'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions',
|
||||
'users_google_id_column': 'Колонка google_id в users',
|
||||
'users_yandex_id_column': 'Колонка yandex_id в users',
|
||||
'users_discord_id_column': 'Колонка discord_id в users',
|
||||
'users_vk_id_column': 'Колонка vk_id в users',
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+78
-19
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -366,32 +367,63 @@ class RemnaWaveAPI:
|
||||
raise RemnaWaveAPIError('Session not initialized. Use async context manager.')
|
||||
|
||||
url = f'{self.base_url}{endpoint}'
|
||||
max_retries = 3
|
||||
base_delay = 1.0
|
||||
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on %s %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
return response_data
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
if attempt < max_retries:
|
||||
delay = base_delay * (2**attempt)
|
||||
logger.warning(
|
||||
'Request failed on %s %s: %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
e,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
|
||||
raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}')
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
@@ -564,6 +596,33 @@ class RemnaWaveAPI:
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для пользователя"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}/accessible-nodes')
|
||||
nodes_data = response.get('response', {}).get('activeNodes', [])
|
||||
result = []
|
||||
for node in nodes_data:
|
||||
# Collect inbounds from activeSquads
|
||||
inbounds: list[str] = []
|
||||
for squad in node.get('activeSquads', []):
|
||||
inbounds.extend(squad.get('activeInbounds', []))
|
||||
result.append(
|
||||
RemnaWaveAccessibleNode(
|
||||
uuid=node['uuid'],
|
||||
node_name=node['nodeName'],
|
||||
country_code=node['countryCode'],
|
||||
config_profile_uuid=node.get('configProfileUuid', ''),
|
||||
config_profile_name=node.get('configProfileName', ''),
|
||||
active_inbounds=inbounds,
|
||||
)
|
||||
)
|
||||
return result
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from . import (
|
||||
backup,
|
||||
blacklist,
|
||||
blocked_users,
|
||||
bot_configuration,
|
||||
bulk_ban,
|
||||
campaigns,
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
"""
|
||||
Хендлеры админ-панели для управления заблокированными пользователями.
|
||||
|
||||
Позволяет сканировать пользователей, выявлять тех, кто заблокировал бота,
|
||||
и выполнять очистку БД и панели Remnawave.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.blocked_users_service import (
|
||||
BlockCheckResult,
|
||||
BlockedUserAction,
|
||||
BlockedUsersService,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums для текстов и callback_data
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BlockedUsersText(Enum):
|
||||
"""Тексты для сообщений модуля заблокированных пользователей."""
|
||||
|
||||
MENU_TITLE = '🔒 <b>Проверка заблокированных пользователей</b>'
|
||||
MENU_DESCRIPTION = (
|
||||
'\n\nЗдесь вы можете проверить, какие пользователи заблокировали бота, '
|
||||
'и очистить их из базы данных и панели Remnawave.\n\n'
|
||||
'<b>Как это работает:</b>\n'
|
||||
'1. Сканирование отправляет тестовый запрос каждому пользователю\n'
|
||||
'2. Если пользователь заблокировал бота - получаем ошибку\n'
|
||||
'3. Можно удалить таких пользователей из БД и/или Remnawave'
|
||||
)
|
||||
|
||||
SCAN_STARTED = '🔄 <b>Сканирование запущено...</b>\n\nЭто может занять несколько минут.'
|
||||
SCAN_PROGRESS = '🔄 <b>Сканирование:</b> {checked}/{total} ({percent}%)'
|
||||
SCAN_COMPLETE = (
|
||||
'✅ <b>Сканирование завершено</b>\n\n'
|
||||
'📊 <b>Результаты:</b>\n'
|
||||
'• Проверено: {total_checked}\n'
|
||||
'• Заблокировали бота: {blocked_count}\n'
|
||||
'• Активных: {active_users}\n'
|
||||
'• Ошибок: {errors}\n'
|
||||
'• Без Telegram ID: {skipped}\n\n'
|
||||
'⏱ Время сканирования: {duration:.1f}с'
|
||||
)
|
||||
SCAN_NO_BLOCKED = '✅ <b>Отлично!</b>\n\nНе найдено пользователей, заблокировавших бота.'
|
||||
|
||||
BLOCKED_LIST_TITLE = '🔒 <b>Заблокированные пользователи</b> ({count})\n\n'
|
||||
BLOCKED_USER_ROW = '• {name} (ID: <code>{telegram_id}</code>)\n'
|
||||
|
||||
CLEANUP_CONFIRM_TITLE = '⚠️ <b>Подтверждение действия</b>\n\n'
|
||||
CLEANUP_CONFIRM_DELETE_DB = (
|
||||
'Вы собираетесь <b>удалить из БД</b> {count} пользователей.\n'
|
||||
'Это действие необратимо!\n\n'
|
||||
'Будут удалены:\n'
|
||||
'• Профили пользователей\n'
|
||||
'• Подписки\n'
|
||||
'• Транзакции\n'
|
||||
'• Реферальные данные'
|
||||
)
|
||||
CLEANUP_CONFIRM_DELETE_REMNAWAVE = (
|
||||
'Вы собираетесь <b>удалить из Remnawave</b> {count} пользователей.\nИх VPN доступ будет полностью отключен.'
|
||||
)
|
||||
CLEANUP_CONFIRM_DELETE_BOTH = (
|
||||
'Вы собираетесь <b>полностью удалить</b> {count} пользователей:\n'
|
||||
'• Из базы данных бота\n'
|
||||
'• Из панели Remnawave\n\n'
|
||||
'Это действие необратимо!'
|
||||
)
|
||||
CLEANUP_CONFIRM_MARK = (
|
||||
'Вы собираетесь <b>пометить как заблокированных</b> {count} пользователей.\n'
|
||||
'Они останутся в БД, но будут помечены статусом "blocked".'
|
||||
)
|
||||
|
||||
CLEANUP_PROGRESS = '🗑 <b>Очистка:</b> {processed}/{total}'
|
||||
CLEANUP_COMPLETE = (
|
||||
'✅ <b>Очистка завершена</b>\n\n'
|
||||
'📊 <b>Результаты:</b>\n'
|
||||
'• Удалено из БД: {deleted_db}\n'
|
||||
'• Удалено из Remnawave: {deleted_remnawave}\n'
|
||||
'• Помечено как заблокированные: {marked}\n'
|
||||
'• Ошибок: {errors}'
|
||||
)
|
||||
|
||||
BUTTON_START_SCAN = '🔍 Начать сканирование'
|
||||
BUTTON_VIEW_BLOCKED = '👥 Список заблокированных ({count})'
|
||||
BUTTON_DELETE_DB = '🗑 Удалить из БД'
|
||||
BUTTON_DELETE_REMNAWAVE = '🌐 Удалить из Remnawave'
|
||||
BUTTON_DELETE_BOTH = '💀 Удалить везде'
|
||||
BUTTON_MARK_BLOCKED = '🚫 Пометить как заблокированных'
|
||||
BUTTON_CONFIRM = '✅ Подтвердить'
|
||||
BUTTON_CANCEL = '❌ Отмена'
|
||||
BUTTON_BACK = '⬅️ Назад'
|
||||
BUTTON_BACK_TO_USERS = '⬅️ К пользователям'
|
||||
|
||||
|
||||
class BlockedUsersCallback(Enum):
|
||||
"""Callback data для кнопок модуля."""
|
||||
|
||||
MENU = 'admin_blocked_users'
|
||||
START_SCAN = 'admin_blocked_scan'
|
||||
VIEW_LIST = 'admin_blocked_list'
|
||||
VIEW_LIST_PAGE = 'admin_blocked_list_page_'
|
||||
ACTION_DELETE_DB = 'admin_blocked_action_db'
|
||||
ACTION_DELETE_REMNAWAVE = 'admin_blocked_action_rw'
|
||||
ACTION_DELETE_BOTH = 'admin_blocked_action_both'
|
||||
ACTION_MARK = 'admin_blocked_action_mark'
|
||||
CONFIRM_PREFIX = 'admin_blocked_confirm_'
|
||||
CANCEL = 'admin_blocked_cancel'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FSM States
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BlockedUsersStates(StatesGroup):
|
||||
"""Состояния FSM для модуля заблокированных пользователей."""
|
||||
|
||||
scanning = State()
|
||||
viewing_results = State()
|
||||
confirming_action = State()
|
||||
processing_cleanup = State()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Keyboards
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_blocked_users_menu_keyboard(
|
||||
scan_result: dict[str, Any] | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура главного меню модуля."""
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_START_SCAN.value,
|
||||
callback_data=BlockedUsersCallback.START_SCAN.value,
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
blocked_count = scan_result.get('blocked_count', 0) if scan_result else 0
|
||||
if blocked_count > 0:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_VIEW_BLOCKED.value.format(count=blocked_count),
|
||||
callback_data=BlockedUsersCallback.VIEW_LIST.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_BACK_TO_USERS.value,
|
||||
callback_data='admin_users',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_blocked_list_keyboard(
|
||||
page: int = 1,
|
||||
total_pages: int = 1,
|
||||
has_blocked: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура списка заблокированных пользователей."""
|
||||
buttons = []
|
||||
|
||||
# Пагинация
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text='⬅️',
|
||||
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page - 1}',
|
||||
)
|
||||
)
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f'{page}/{total_pages}',
|
||||
callback_data='noop',
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text='➡️',
|
||||
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page + 1}',
|
||||
)
|
||||
)
|
||||
buttons.append(nav_row)
|
||||
|
||||
# Действия
|
||||
if has_blocked:
|
||||
buttons.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_DB.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_DB.value,
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_REMNAWAVE.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_BOTH.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_BOTH.value,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_MARK_BLOCKED.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_MARK.value,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_BACK.value,
|
||||
callback_data=BlockedUsersCallback.MENU.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_confirm_keyboard(action: BlockedUserAction) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура подтверждения действия."""
|
||||
action_map = {
|
||||
BlockedUserAction.DELETE_FROM_DB: 'db',
|
||||
BlockedUserAction.DELETE_FROM_REMNAWAVE: 'rw',
|
||||
BlockedUserAction.DELETE_BOTH: 'both',
|
||||
BlockedUserAction.MARK_AS_BLOCKED: 'mark',
|
||||
}
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_CONFIRM.value,
|
||||
callback_data=f'{BlockedUsersCallback.CONFIRM_PREFIX.value}{action_map[action]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_CANCEL.value,
|
||||
callback_data=BlockedUsersCallback.CANCEL.value,
|
||||
),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blocked_users_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Показывает главное меню модуля заблокированных пользователей."""
|
||||
data = await state.get_data()
|
||||
scan_result = data.get('blocked_users_scan_result')
|
||||
|
||||
text = BlockedUsersText.MENU_TITLE.value + BlockedUsersText.MENU_DESCRIPTION.value
|
||||
|
||||
if scan_result:
|
||||
text += (
|
||||
f'\n\n📊 <b>Последнее сканирование:</b>\n'
|
||||
f'• Заблокированных: {scan_result.get("blocked_count", 0)}\n'
|
||||
f'• Активных: {scan_result.get("active_users", 0)}'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(scan_result),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_scan(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
) -> None:
|
||||
"""Запускает сканирование пользователей."""
|
||||
await state.set_state(BlockedUsersStates.scanning)
|
||||
|
||||
# Отправляем начальное сообщение
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.SCAN_STARTED.value,
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
|
||||
service = BlockedUsersService(bot)
|
||||
last_update_time = datetime.now(tz=UTC)
|
||||
|
||||
async def progress_callback(checked: int, total: int) -> None:
|
||||
nonlocal last_update_time
|
||||
now = datetime.now(tz=UTC)
|
||||
# Обновляем сообщение не чаще раза в 3 секунды
|
||||
if (now - last_update_time).total_seconds() >= 3:
|
||||
last_update_time = now
|
||||
percent = int(checked / total * 100) if total > 0 else 0
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.SCAN_PROGRESS.value.format(
|
||||
checked=checked,
|
||||
total=total,
|
||||
percent=percent,
|
||||
),
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
except Exception:
|
||||
pass # Игнорируем ошибки обновления сообщения
|
||||
|
||||
# Выполняем сканирование
|
||||
result = await service.scan_all_users(
|
||||
db,
|
||||
only_active=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Сериализуем результат в dict для Redis и keyboard
|
||||
scan_result_dict = {
|
||||
'total_checked': result.total_checked,
|
||||
'blocked_count': result.blocked_count,
|
||||
'active_users': result.active_users,
|
||||
'errors': result.errors,
|
||||
'skipped_no_telegram': result.skipped_no_telegram,
|
||||
'scan_duration_seconds': result.scan_duration_seconds,
|
||||
}
|
||||
|
||||
# Сохраняем результат в state
|
||||
await state.update_data(
|
||||
blocked_users_scan_result=scan_result_dict,
|
||||
blocked_users_list=[
|
||||
{
|
||||
'user_id': u.user_id,
|
||||
'telegram_id': u.telegram_id,
|
||||
'username': u.username,
|
||||
'full_name': u.full_name,
|
||||
'remnawave_uuid': u.remnawave_uuid,
|
||||
}
|
||||
for u in result.blocked_users
|
||||
],
|
||||
)
|
||||
|
||||
await state.set_state(BlockedUsersStates.viewing_results)
|
||||
|
||||
# Формируем итоговое сообщение
|
||||
if result.blocked_count == 0:
|
||||
text = BlockedUsersText.SCAN_NO_BLOCKED.value
|
||||
else:
|
||||
text = BlockedUsersText.SCAN_COMPLETE.value.format(
|
||||
total_checked=result.total_checked,
|
||||
blocked_count=result.blocked_count,
|
||||
active_users=result.active_users,
|
||||
errors=result.errors,
|
||||
skipped=result.skipped_no_telegram,
|
||||
duration=result.scan_duration_seconds,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(scan_result_dict),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blocked_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
page: int = 1,
|
||||
) -> None:
|
||||
"""Показывает список заблокированных пользователей."""
|
||||
data = await state.get_data()
|
||||
blocked_list: list[dict[str, Any]] = data.get('blocked_users_list', [])
|
||||
|
||||
if not blocked_list:
|
||||
await callback.answer('Нет заблокированных пользователей', show_alert=True)
|
||||
return
|
||||
|
||||
# Пагинация
|
||||
per_page = 15
|
||||
total_pages = (len(blocked_list) + per_page - 1) // per_page
|
||||
page = max(1, min(page, total_pages))
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_users = blocked_list[start_idx:end_idx]
|
||||
|
||||
text = BlockedUsersText.BLOCKED_LIST_TITLE.value.format(count=len(blocked_list))
|
||||
|
||||
for user_data in page_users:
|
||||
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
|
||||
telegram_id = user_data.get('telegram_id', '?')
|
||||
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
|
||||
name=name,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_list_keyboard(page, total_pages, bool(blocked_list)),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_blocked_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает пагинацию списка заблокированных."""
|
||||
try:
|
||||
page = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
page = 1
|
||||
|
||||
await show_blocked_list(callback, db_user, state, page)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_action_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
action: BlockedUserAction,
|
||||
) -> None:
|
||||
"""Показывает подтверждение действия."""
|
||||
data = await state.get_data()
|
||||
blocked_list = data.get('blocked_users_list', [])
|
||||
count = len(blocked_list)
|
||||
|
||||
if count == 0:
|
||||
await callback.answer('Нет пользователей для обработки', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(BlockedUsersStates.confirming_action)
|
||||
await state.update_data(pending_action=action.value)
|
||||
|
||||
text = BlockedUsersText.CLEANUP_CONFIRM_TITLE.value
|
||||
|
||||
if action == BlockedUserAction.DELETE_FROM_DB:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_DB.value.format(count=count)
|
||||
elif action == BlockedUserAction.DELETE_FROM_REMNAWAVE:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_REMNAWAVE.value.format(count=count)
|
||||
elif action == BlockedUserAction.DELETE_BOTH:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_BOTH.value.format(count=count)
|
||||
elif action == BlockedUserAction.MARK_AS_BLOCKED:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_MARK.value.format(count=count)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_confirm_keyboard(action),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_db(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор удаления из БД."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_DB)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_remnawave(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор удаления из Remnawave."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_REMNAWAVE)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_both(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор полного удаления."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_BOTH)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_mark(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор пометки как заблокированных."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.MARK_AS_BLOCKED)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_confirm_action(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
) -> None:
|
||||
"""Выполняет подтвержденное действие."""
|
||||
data = await state.get_data()
|
||||
blocked_list = data.get('blocked_users_list', [])
|
||||
|
||||
# Определяем действие из callback_data
|
||||
action_code = callback.data.replace(BlockedUsersCallback.CONFIRM_PREFIX.value, '')
|
||||
action_map = {
|
||||
'db': BlockedUserAction.DELETE_FROM_DB,
|
||||
'rw': BlockedUserAction.DELETE_FROM_REMNAWAVE,
|
||||
'both': BlockedUserAction.DELETE_BOTH,
|
||||
'mark': BlockedUserAction.MARK_AS_BLOCKED,
|
||||
}
|
||||
action = action_map.get(action_code)
|
||||
|
||||
if not action:
|
||||
await callback.answer('Неизвестное действие', show_alert=True)
|
||||
return
|
||||
|
||||
if not blocked_list:
|
||||
await callback.answer('Нет пользователей для обработки', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(BlockedUsersStates.processing_cleanup)
|
||||
|
||||
# Преобразуем обратно в BlockCheckResult
|
||||
blocked_results = [
|
||||
BlockCheckResult(
|
||||
user_id=u['user_id'],
|
||||
telegram_id=u['telegram_id'],
|
||||
username=u['username'],
|
||||
full_name=u['full_name'],
|
||||
status=None, # type: ignore
|
||||
remnawave_uuid=u['remnawave_uuid'],
|
||||
)
|
||||
for u in blocked_list
|
||||
]
|
||||
|
||||
service = BlockedUsersService(bot)
|
||||
last_update_time = datetime.now(tz=UTC)
|
||||
|
||||
async def progress_callback(processed: int, total_count: int) -> None:
|
||||
nonlocal last_update_time
|
||||
now = datetime.now(tz=UTC)
|
||||
if (now - last_update_time).total_seconds() >= 2:
|
||||
last_update_time = now
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.CLEANUP_PROGRESS.value.format(
|
||||
processed=processed,
|
||||
total=total_count,
|
||||
),
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Выполняем очистку
|
||||
result = await service.cleanup_blocked_users(
|
||||
db,
|
||||
blocked_results,
|
||||
action,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Очищаем сохраненные данные
|
||||
await state.update_data(
|
||||
blocked_users_scan_result=None,
|
||||
blocked_users_list=[],
|
||||
pending_action=None,
|
||||
)
|
||||
await state.set_state(None)
|
||||
|
||||
# Показываем результат
|
||||
text = BlockedUsersText.CLEANUP_COMPLETE.value.format(
|
||||
deleted_db=result.deleted_from_db,
|
||||
deleted_remnawave=result.deleted_from_remnawave,
|
||||
marked=result.marked_as_blocked,
|
||||
errors=len(result.errors),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Очистка заблокированных пользователей завершена: '
|
||||
f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, '
|
||||
f'marked={result.marked_as_blocked}, errors={len(result.errors)}'
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Отменяет текущее действие и возвращает в меню."""
|
||||
await state.update_data(pending_action=None)
|
||||
await state.set_state(BlockedUsersStates.viewing_results)
|
||||
await show_blocked_users_menu(callback, db_user, state)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
"""Регистрирует хендлеры модуля заблокированных пользователей."""
|
||||
|
||||
# Главное меню
|
||||
dp.callback_query.register(
|
||||
show_blocked_users_menu,
|
||||
F.data == BlockedUsersCallback.MENU.value,
|
||||
)
|
||||
|
||||
# Сканирование
|
||||
dp.callback_query.register(
|
||||
start_scan,
|
||||
F.data == BlockedUsersCallback.START_SCAN.value,
|
||||
)
|
||||
|
||||
# Список заблокированных
|
||||
dp.callback_query.register(
|
||||
show_blocked_list,
|
||||
F.data == BlockedUsersCallback.VIEW_LIST.value,
|
||||
)
|
||||
|
||||
# Пагинация списка
|
||||
dp.callback_query.register(
|
||||
handle_blocked_list_pagination,
|
||||
F.data.startswith(BlockedUsersCallback.VIEW_LIST_PAGE.value),
|
||||
)
|
||||
|
||||
# Выбор действий
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_db,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_DB.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_remnawave,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_both,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_BOTH.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_mark,
|
||||
F.data == BlockedUsersCallback.ACTION_MARK.value,
|
||||
)
|
||||
|
||||
# Подтверждение действий
|
||||
dp.callback_query.register(
|
||||
handle_confirm_action,
|
||||
F.data.startswith(BlockedUsersCallback.CONFIRM_PREFIX.value),
|
||||
)
|
||||
|
||||
# Отмена
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data == BlockedUsersCallback.CANCEL.value,
|
||||
)
|
||||
@@ -1015,6 +1015,10 @@ async def show_virtual_participants(
|
||||
text='➕ Добавить',
|
||||
callback_data=f'admin_contest_vp_add_{contest_id}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='🎭 Массовка',
|
||||
callback_data=f'admin_contest_vp_mass_{contest_id}',
|
||||
),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
@@ -1160,7 +1164,10 @@ async def delete_virtual_participant_handler(
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
|
||||
[
|
||||
types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
|
||||
types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
for v in vps:
|
||||
@@ -1180,6 +1187,164 @@ async def delete_virtual_participant_handler(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_mass_virtual_participants(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Начинает массовое создание виртуальных участников (массовка)."""
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
await state.set_state(AdminStates.adding_mass_virtual_count)
|
||||
await state.update_data(mass_vp_contest_id=contest_id)
|
||||
|
||||
text = """
|
||||
🎭 <b>Массовка — массовое создание виртуальных участников</b>
|
||||
|
||||
<i>Для чего это нужно?</i>
|
||||
Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
|
||||
|
||||
Это помогает:
|
||||
• Мотивировать реальных участников соревноваться
|
||||
• Задать планку для участия
|
||||
• Сделать конкурс более живым
|
||||
|
||||
<b>Введите количество призраков для создания:</b>
|
||||
<i>(от 1 до 50)</i>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_mass_virtual_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает количество призраков для массового создания."""
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1 or count > 50:
|
||||
await message.answer(
|
||||
'❌ Введите число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
'❌ Введите корректное число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(mass_vp_count=count)
|
||||
await state.set_state(AdminStates.adding_mass_virtual_referrals)
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data.get('mass_vp_contest_id')
|
||||
|
||||
await message.answer(
|
||||
f'✅ Будет создано <b>{count}</b> призраков.\n\n'
|
||||
f'<b>Введите количество рефералов у каждого:</b>\n'
|
||||
f'<i>(от 1 до 100)</i>',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_mass_virtual_referrals(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Создаёт массовку призраков с рандомными именами."""
|
||||
import random
|
||||
import string
|
||||
|
||||
try:
|
||||
referrals_count = int(message.text.strip())
|
||||
if referrals_count < 1 or referrals_count > 100:
|
||||
await message.answer('❌ Введите число от 1 до 100:')
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer('❌ Введите корректное число от 1 до 100:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data.get('mass_vp_contest_id')
|
||||
ghost_count = data.get('mass_vp_count', 1)
|
||||
|
||||
await state.clear()
|
||||
|
||||
# Генерируем и создаём призраков
|
||||
created = []
|
||||
for _ in range(ghost_count):
|
||||
# Рандомное имя до 5 символов (буквы + цифры)
|
||||
name_length = random.randint(3, 5)
|
||||
name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
|
||||
|
||||
vp = await add_virtual_participant(db, contest_id, name, referrals_count)
|
||||
created.append(vp)
|
||||
|
||||
# Показываем результат
|
||||
text = f"""
|
||||
✅ <b>Массовка создана!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Создано призраков: {len(created)}
|
||||
• Рефералов у каждого: {referrals_count}
|
||||
• Всего виртуальных рефералов: {len(created) * referrals_count}
|
||||
|
||||
👻 <b>Созданные призраки:</b>
|
||||
"""
|
||||
for vp in created[:10]:
|
||||
text += f'• {vp.display_name} — {vp.referral_count} реф.\n'
|
||||
|
||||
if len(created) > 10:
|
||||
text += f'<i>... и ещё {len(created) - 10}</i>\n'
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}'
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_virtual_participant(
|
||||
@@ -1282,7 +1447,10 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
|
||||
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
|
||||
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
|
||||
dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
|
||||
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
|
||||
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
|
||||
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
|
||||
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
|
||||
dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
|
||||
dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
|
||||
|
||||
+295
-143
@@ -127,58 +127,78 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
|
||||
|
||||
async def _persist_broadcast_result(
|
||||
db: AsyncSession,
|
||||
broadcast_history: BroadcastHistory,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||||
"""
|
||||
Сохраняет результаты рассылки в НОВОЙ сессии.
|
||||
|
||||
# Сохраняем ID и время завершения в локальные переменные ДО операций с БД,
|
||||
# чтобы избежать обращения к атрибутам отсоединенного объекта при потере соединения
|
||||
broadcast_id = broadcast_history.id
|
||||
ВАЖНО: Используем свежую сессию вместо переданной, потому что за время
|
||||
долгой рассылки (минуты/часы) оригинальное соединение гарантированно
|
||||
закроется по таймауту PostgreSQL (idle_in_transaction_session_timeout).
|
||||
|
||||
Args:
|
||||
broadcast_id: ID записи BroadcastHistory (не ORM-объект!)
|
||||
sent_count: Количество успешно отправленных сообщений
|
||||
failed_count: Количество неудачных отправок
|
||||
status: Финальный статус рассылки ('completed', 'partial', 'failed')
|
||||
"""
|
||||
completed_at = datetime.utcnow()
|
||||
max_retries = 3
|
||||
retry_delay = 1.0
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = completed_at
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast_history = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast_history:
|
||||
logger.critical(
|
||||
'Не удалось найти запись BroadcastHistory #%s для записи результатов',
|
||||
broadcast_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
return
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
'Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз',
|
||||
exc_info=error,
|
||||
)
|
||||
await db.rollback()
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = completed_at
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_session:
|
||||
retry_history = await retry_session.get(BroadcastHistory, broadcast_id)
|
||||
if not retry_history:
|
||||
logger.critical(
|
||||
'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов',
|
||||
logger.info(
|
||||
'Результаты рассылки сохранены (id=%s, sent=%d, failed=%d, status=%s)',
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status,
|
||||
)
|
||||
return
|
||||
|
||||
retry_history.sent_count = sent_count
|
||||
retry_history.failed_count = failed_count
|
||||
retry_history.status = status
|
||||
retry_history.completed_at = completed_at
|
||||
await retry_session.commit()
|
||||
logger.info(
|
||||
'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)',
|
||||
broadcast_id,
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
'Ошибка соединения при сохранении результатов рассылки (попытка %d/%d): %s',
|
||||
attempt,
|
||||
max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.critical(
|
||||
'Не удалось сохранить результаты рассылки после восстановления подключения',
|
||||
exc_info=retry_error,
|
||||
)
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.critical(
|
||||
'Не удалось сохранить результаты рассылки после %d попыток (id=%s)',
|
||||
max_retries,
|
||||
broadcast_id,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
'Неожиданная ошибка при сохранении результатов рассылки (id=%s)',
|
||||
broadcast_id,
|
||||
exc_info=error,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -1113,15 +1133,38 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
# =========================================================================
|
||||
# КРИТИЧНО: Извлекаем ВСЕ скалярные значения из ORM-объектов СЕЙЧАС,
|
||||
# пока сессия активна. После начала рассылки соединение с БД может
|
||||
# закрыться по таймауту, и любое обращение к атрибутам ORM вызовет:
|
||||
# - MissingGreenlet (lazy loading вне async контекста)
|
||||
# - InterfaceError (соединение закрыто)
|
||||
# =========================================================================
|
||||
admin_id: int = db_user.id
|
||||
admin_name: str = db_user.full_name # property, читает first_name/last_name
|
||||
admin_telegram_id: int | None = db_user.telegram_id
|
||||
admin_language: str = db_user.language
|
||||
|
||||
await safe_edit_or_send_text(
|
||||
callback, '📨 Начинаю рассылку...\n\n⏳ Это может занять несколько минут.', reply_markup=None, parse_mode='HTML'
|
||||
callback,
|
||||
'📨 <b>Подготовка рассылки...</b>\n\n⏳ Загружаю список получателей...',
|
||||
reply_markup=None,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
# Загружаем пользователей и сразу извлекаем telegram_id в список
|
||||
# чтобы не обращаться к ORM-объектам во время долгой рассылки
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
users_orm = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
users_orm = await get_target_users(db, target)
|
||||
|
||||
# Извлекаем только telegram_id - это всё что нужно для отправки
|
||||
# Фильтруем None (email-only пользователи)
|
||||
recipient_telegram_ids: list[int] = [user.telegram_id for user in users_orm if user.telegram_id is not None]
|
||||
total_users_count = len(users_orm)
|
||||
|
||||
# Создаём запись истории рассылки
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
@@ -1129,145 +1172,252 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
total_count=total_users_count,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
admin_id=db_user.id,
|
||||
admin_name=db_user.full_name,
|
||||
admin_id=admin_id,
|
||||
admin_name=admin_name,
|
||||
status='in_progress',
|
||||
)
|
||||
db.add(broadcast_history)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast_history)
|
||||
|
||||
# Сохраняем ID - это единственное что нам нужно после коммита
|
||||
broadcast_id: int = broadcast_history.id
|
||||
|
||||
# =========================================================================
|
||||
# С этого момента НЕ используем db сессию и ORM-объекты!
|
||||
# Работаем только со скалярными значениями.
|
||||
# =========================================================================
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, admin_language)
|
||||
|
||||
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
|
||||
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
|
||||
max_concurrent_sends = 5
|
||||
per_message_delay = 0.05
|
||||
semaphore = asyncio.Semaphore(max_concurrent_sends)
|
||||
# =========================================================================
|
||||
# Rate limiting: Telegram допускает ~30 msg/sec для бота.
|
||||
# Используем batch_size=25 + 1 сек задержка между батчами = ~25 msg/sec
|
||||
# с запасом, чтобы не получать FloodWait.
|
||||
# Semaphore=25 — все сообщения батча отправляются параллельно.
|
||||
# =========================================================================
|
||||
_BATCH_SIZE = 25
|
||||
_BATCH_DELAY = 1.0 # секунда между батчами
|
||||
_MAX_SEND_RETRIES = 3
|
||||
# Обновляем прогресс каждые N батчей (не каждое сообщение — иначе FloodWait на edit_text)
|
||||
_PROGRESS_UPDATE_INTERVAL = max(1, 500 // _BATCH_SIZE) # ~каждые 500 сообщений
|
||||
# Минимальный интервал между обновлениями прогресса (секунды)
|
||||
_PROGRESS_MIN_INTERVAL = 5.0
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
logger.debug('Пропуск email-пользователя %s при рассылке', user.id)
|
||||
return False, None
|
||||
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
|
||||
flood_wait_until: float = 0.0
|
||||
|
||||
async with semaphore:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == 'photo':
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
elif media_type == 'video':
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
elif media_type == 'document':
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
async def send_single_broadcast(telegram_id: int) -> bool:
|
||||
"""Отправляет одно сообщение. Возвращает True при успехе."""
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_MAX_SEND_RETRIES):
|
||||
# Глобальная пауза при FloodWait
|
||||
now = asyncio.get_event_loop().time()
|
||||
if flood_wait_until > now:
|
||||
await asyncio.sleep(flood_wait_until - now)
|
||||
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
send_method = {
|
||||
'photo': callback.bot.send_photo,
|
||||
'video': callback.bot.send_video,
|
||||
'document': callback.bot.send_document,
|
||||
}.get(media_type)
|
||||
if send_method:
|
||||
media_kwarg = {
|
||||
'photo': 'photo',
|
||||
'video': 'video',
|
||||
'document': 'document',
|
||||
}[media_type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Неизвестный media_type — отправляем как текст
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
|
||||
await asyncio.sleep(per_message_delay)
|
||||
return True, user.telegram_id
|
||||
except TelegramRetryAfter as e:
|
||||
retry_delay = min(e.retry_after + 1, 30)
|
||||
logger.warning(f'Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек.')
|
||||
await asyncio.sleep(retry_delay)
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь мог удалить бота или запретить сообщения
|
||||
logger.info(f'Рассылка недоступна для пользователя {user.telegram_id}: Forbidden')
|
||||
return False, user.telegram_id
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f'Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}')
|
||||
return False, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}'
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# Глобальная пауза — тормозим все корутины
|
||||
wait_seconds = e.retry_after + 1
|
||||
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
|
||||
logger.warning(
|
||||
'FloodWait: Telegram просит подождать %d сек (пользователь %d, попытка %d/%d)',
|
||||
e.retry_after,
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.debug('BadRequest при рассылке пользователю %d: %s', telegram_id, e)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки пользователю %d (попытка %d/%d): %s',
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_RETRIES,
|
||||
e,
|
||||
)
|
||||
if attempt < _MAX_SEND_RETRIES - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False, user.telegram_id
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 50
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i : i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
# =========================================================================
|
||||
# Прогресс-бар в реальном времени (как в сканере заблокированных)
|
||||
# =========================================================================
|
||||
total_recipients = len(recipient_telegram_ids)
|
||||
last_progress_update: float = 0.0
|
||||
# ID сообщения, которое обновляем (может быть заменено при ошибке)
|
||||
progress_message = callback.message
|
||||
|
||||
def _build_progress_text(
|
||||
current_sent: int,
|
||||
current_failed: int,
|
||||
total: int,
|
||||
phase: str = 'sending',
|
||||
) -> str:
|
||||
processed = current_sent + current_failed
|
||||
percent = round(processed / total * 100, 1) if total > 0 else 0
|
||||
bar_length = 20
|
||||
filled = int(bar_length * processed / total) if total > 0 else 0
|
||||
bar = '█' * filled + '░' * (bar_length - filled)
|
||||
|
||||
if phase == 'sending':
|
||||
return (
|
||||
f'📨 <b>Рассылка в процессе...</b>\n\n'
|
||||
f'[{bar}] {percent}%\n\n'
|
||||
f'📊 <b>Прогресс:</b>\n'
|
||||
f'• Отправлено: {current_sent}\n'
|
||||
f'• Ошибок: {current_failed}\n'
|
||||
f'• Обработано: {processed}/{total}\n\n'
|
||||
f'⏳ Не закрывайте диалог — рассылка продолжается...'
|
||||
)
|
||||
return ''
|
||||
|
||||
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
|
||||
"""Безопасно обновляет сообщение с прогрессом."""
|
||||
nonlocal last_progress_update, progress_message
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_progress_update < _PROGRESS_MIN_INTERVAL:
|
||||
return
|
||||
last_progress_update = now
|
||||
|
||||
text = _build_progress_text(current_sent, current_failed, total_recipients)
|
||||
try:
|
||||
await progress_message.edit_text(text, parse_mode='HTML')
|
||||
except TelegramRetryAfter as e:
|
||||
# Не паникуем — пропускаем обновление прогресса
|
||||
logger.debug('FloodWait при обновлении прогресса, пропускаем: %d сек', e.retry_after)
|
||||
except TelegramBadRequest:
|
||||
# Сообщение удалено или контент не изменился — отправляем новое
|
||||
try:
|
||||
progress_message = await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Не ломаем рассылку из-за ошибок обновления прогресса
|
||||
|
||||
# Первое обновление прогресса
|
||||
await _update_progress_message(0, 0)
|
||||
|
||||
# =========================================================================
|
||||
# Основной цикл рассылки — батчами по _BATCH_SIZE
|
||||
# =========================================================================
|
||||
for batch_idx, i in enumerate(range(0, total_recipients, _BATCH_SIZE)):
|
||||
batch = recipient_telegram_ids[i : i + _BATCH_SIZE]
|
||||
|
||||
# Отправляем батч параллельно
|
||||
results = await asyncio.gather(
|
||||
*[send_single_broadcast(tid) for tid in batch],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
if isinstance(result, bool):
|
||||
if result:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
logger.error('Необработанное исключение в рассылке: %s', result)
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.25)
|
||||
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
|
||||
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
|
||||
await _update_progress_message(sent_count, failed_count)
|
||||
|
||||
# Задержка между батчами для соблюдения rate limits
|
||||
await asyncio.sleep(_BATCH_DELAY)
|
||||
|
||||
# Учитываем пропущенных email-only пользователей
|
||||
skipped_email_users = total_users_count - total_recipients
|
||||
if skipped_email_users > 0:
|
||||
logger.info('Пропущено %d email-only пользователей при рассылке', skipped_email_users)
|
||||
|
||||
status = 'completed' if failed_count == 0 else 'partial'
|
||||
|
||||
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
|
||||
await _persist_broadcast_result(
|
||||
db=db,
|
||||
broadcast_history=broadcast_history,
|
||||
broadcast_id=broadcast_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
status=status,
|
||||
)
|
||||
|
||||
media_info = ''
|
||||
if has_media:
|
||||
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}'
|
||||
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
|
||||
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
result_text = (
|
||||
f'✅ <b>Рассылка завершена!</b>\n\n'
|
||||
f'📊 <b>Результат:</b>\n'
|
||||
f'• Отправлено: {sent_count}\n'
|
||||
f'• Не доставлено: {failed_count}\n'
|
||||
f'• Всего пользователей: {total_users_count}\n'
|
||||
f'• Успешность: {success_rate}%{media_info}\n\n'
|
||||
f'<b>Администратор:</b> {admin_name}'
|
||||
)
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
back_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await progress_message.edit_text(result_text, reply_markup=back_keyboard, parse_mode='HTML')
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if (
|
||||
@@ -1275,22 +1425,24 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
or 'there is no text' in error_msg
|
||||
or "message can't be edited" in error_msg
|
||||
):
|
||||
# Сообщение удалено или это медиа - отправляем новое
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]
|
||||
]
|
||||
),
|
||||
reply_markup=back_keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
await state.clear()
|
||||
logger.info(f'Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})')
|
||||
logger.info(
|
||||
'Рассылка завершена админом %s: sent=%d, failed=%d, total=%d (медиа: %s)',
|
||||
admin_telegram_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
total_users_count,
|
||||
has_media,
|
||||
)
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
@@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return 'CryptoBot'
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.get_freekassa_display_name()
|
||||
return method.value
|
||||
|
||||
|
||||
@@ -144,6 +148,18 @@ def _status_info(
|
||||
}
|
||||
return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
|
||||
'created': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
|
||||
'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')),
|
||||
'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
|
||||
'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
|
||||
'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')),
|
||||
'error': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')),
|
||||
}
|
||||
return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
|
||||
|
||||
return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')
|
||||
|
||||
|
||||
@@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {'active'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', ''}
|
||||
return status in {'pending', 'created', ''}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing', ''}
|
||||
return False
|
||||
|
||||
|
||||
@@ -184,6 +202,7 @@ def _build_list_keyboard(
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
has_checkable: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
@@ -204,6 +223,28 @@ def _build_list_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопка "Проверить все" если есть что проверять
|
||||
if has_checkable:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'),
|
||||
callback_data='admin_payments_check_all',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопка экспорта если есть платежи
|
||||
if records:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'),
|
||||
callback_data='admin_payments_export',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
@@ -485,11 +526,22 @@ async def show_payments_overview(
|
||||
|
||||
lines = [header, '', description]
|
||||
|
||||
# Проверяем есть ли платежи для массовой проверки
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
has_checkable = len(checkable_records) > 0
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append('')
|
||||
lines.append(notice)
|
||||
if has_checkable:
|
||||
lines.append('')
|
||||
lines.append(
|
||||
texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format(
|
||||
count=len(checkable_records)
|
||||
)
|
||||
)
|
||||
else:
|
||||
empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.')
|
||||
lines.append('')
|
||||
@@ -500,6 +552,7 @@ async def show_payments_overview(
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
has_checkable=has_checkable,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -550,28 +603,42 @@ async def manual_check_payment(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info('manual_check_payment called: %s', callback.data)
|
||||
|
||||
parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_')
|
||||
if not parsed:
|
||||
logger.warning('Failed to parse: %s', callback.data)
|
||||
await callback.answer('❌ Invalid payment reference', show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
logger.info('Checking payment: method=%s, id=%s', method, payment_id)
|
||||
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
logger.warning('Payment not found: method=%s, id=%s', method, payment_id)
|
||||
await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True)
|
||||
return
|
||||
|
||||
logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid)
|
||||
|
||||
if not _is_checkable(record):
|
||||
logger.info('Payment not checkable: method=%s, status=%s', method, record.status)
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info('Running manual check...')
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
logger.info('Check result: updated=%s', updated is not None)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
@@ -597,7 +664,189 @@ async def manual_check_payment(
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_all_payments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Массовая проверка всех ожидающих платежей."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info('check_all_payments called')
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получаем все ожидающие платежи
|
||||
records = await list_recent_pending_payments(db)
|
||||
logger.info('Found %d total records', len(records))
|
||||
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
logger.info('Found %d checkable records', len(checkable_records))
|
||||
|
||||
if not checkable_records:
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)),
|
||||
)
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
checked = 0
|
||||
confirmed = 0
|
||||
failed = 0
|
||||
|
||||
for record in checkable_records:
|
||||
try:
|
||||
logger.info('Checking %s payment id=%s', record.method.value, record.local_id)
|
||||
updated = await run_manual_check(db, record.method, record.local_id, payment_service)
|
||||
checked += 1
|
||||
logger.info('Check result: is_paid=%s', updated.is_paid if updated else None)
|
||||
if updated and updated.is_paid and not record.is_paid:
|
||||
confirmed += 1
|
||||
except Exception as e:
|
||||
logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True)
|
||||
failed += 1
|
||||
|
||||
logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed)
|
||||
|
||||
# Показываем результат
|
||||
result_lines = [
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 <b>Результат проверки</b>'),
|
||||
'',
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked),
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed),
|
||||
]
|
||||
if failed:
|
||||
result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed))
|
||||
|
||||
# Перезагружаем список платежей
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page_records = records[:PAGE_SIZE]
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
|
||||
result_lines.append('')
|
||||
result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 <b>Top-up verification</b>'))
|
||||
|
||||
if page_records:
|
||||
result_lines.append('')
|
||||
for idx, record in enumerate(page_records, start=1):
|
||||
result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
result_lines.append('')
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=1,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
has_checkable=len(checkable_records) > 0,
|
||||
)
|
||||
|
||||
logger.info('Updating message with results...')
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(result_lines),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info('Message updated successfully')
|
||||
except Exception as e:
|
||||
logger.error('Failed to update message: %s', e, exc_info=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def export_payments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Экспорт данных платежей в JSON файл."""
|
||||
import json
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
|
||||
if not records:
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Формируем данные для экспорта
|
||||
export_data = []
|
||||
for record in records:
|
||||
payment = record.payment
|
||||
user = record.user
|
||||
|
||||
payment_data = {
|
||||
'id': record.local_id,
|
||||
'method': record.method.value,
|
||||
'method_display': _method_display(record.method),
|
||||
'identifier': record.identifier,
|
||||
'amount_kopeks': record.amount_kopeks,
|
||||
'amount_rubles': record.amount_kopeks / 100,
|
||||
'status': record.status,
|
||||
'is_paid': record.is_paid,
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None,
|
||||
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'telegram_id': user.telegram_id,
|
||||
'username': user.username,
|
||||
'full_name': user.full_name,
|
||||
},
|
||||
}
|
||||
|
||||
# Добавляем специфичные поля в зависимости от метода
|
||||
if hasattr(payment, 'order_id'):
|
||||
payment_data['order_id'] = payment.order_id
|
||||
if hasattr(payment, 'payment_url'):
|
||||
payment_data['payment_url'] = payment.payment_url
|
||||
if hasattr(payment, 'callback_payload'):
|
||||
payment_data['callback_payload'] = payment.callback_payload
|
||||
|
||||
export_data.append(payment_data)
|
||||
|
||||
# Создаём JSON файл
|
||||
json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str)
|
||||
file_bytes = json_content.encode('utf-8')
|
||||
|
||||
# Отправляем файл
|
||||
from datetime import datetime
|
||||
|
||||
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=BufferedInputFile(file_bytes, filename=filename),
|
||||
caption=texts.t(
|
||||
'ADMIN_PAYMENTS_EXPORT_CAPTION',
|
||||
'📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}',
|
||||
).format(
|
||||
count=len(export_data),
|
||||
paid=sum(1 for r in export_data if r['is_paid']),
|
||||
pending=sum(1 for r in export_data if not r['is_paid']),
|
||||
),
|
||||
)
|
||||
|
||||
await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен'))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all')
|
||||
dp.callback_query.register(export_payments, F.data == 'admin_payments_export')
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_'))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
|
||||
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
|
||||
keyboard_rows = [
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
|
||||
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
|
||||
[types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
|
||||
# Кнопка заявок на вывод (если функция включена)
|
||||
@@ -650,11 +651,812 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
)
|
||||
|
||||
|
||||
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
|
||||
"""Возвращает начальную и конечную даты для заданного периода."""
|
||||
now = datetime.datetime.now()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if period == 'today':
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
elif period == 'yesterday':
|
||||
start_date = today - datetime.timedelta(days=1)
|
||||
end_date = today
|
||||
elif period == 'week':
|
||||
start_date = today - datetime.timedelta(days=7)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
elif period == 'month':
|
||||
start_date = today - datetime.timedelta(days=30)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
else:
|
||||
# По умолчанию — сегодня
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def _get_period_display_name(period: str) -> str:
|
||||
"""Возвращает человекочитаемое название периода."""
|
||||
names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
|
||||
return names.get(period, 'сегодня')
|
||||
|
||||
|
||||
async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str):
|
||||
"""Внутренняя функция для отображения диагностики за указанный период."""
|
||||
try:
|
||||
await callback.answer('Анализирую логи...')
|
||||
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
|
||||
# Сохраняем период в state
|
||||
await state.update_data(diagnostics_period=period)
|
||||
from app.states import AdminStates
|
||||
|
||||
await state.set_state(AdminStates.referral_diagnostics_period)
|
||||
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
|
||||
# Формируем отчёт
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Диагностика рефералов — {period_display}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
• Уникальных пользователей: {report.unique_users_clicked}
|
||||
• Потерянных рефералов: {len(report.lost_referrals)}
|
||||
"""
|
||||
|
||||
if report.lost_referrals:
|
||||
text += '\n<b>❌ Потерянные рефералы:</b>\n'
|
||||
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
|
||||
|
||||
for i, lost in enumerate(report.lost_referrals[:15], 1):
|
||||
# Статус пользователя
|
||||
if not lost.registered:
|
||||
status = '⚠️ Не в БД'
|
||||
elif not lost.has_referrer:
|
||||
status = '❌ Без реферера'
|
||||
else:
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
# Время
|
||||
time_str = lost.click_time.strftime('%H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
else:
|
||||
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
|
||||
|
||||
# Информация о логах
|
||||
log_path = referral_diagnostics_service.log_path
|
||||
log_exists = log_path.exists()
|
||||
log_size = log_path.stat().st_size if log_exists else 0
|
||||
|
||||
text += f'\n<i>📂 {log_path.name}'
|
||||
if log_exists:
|
||||
text += f' ({log_size / 1024:.0f} KB)'
|
||||
text += f' | Строк: {report.lines_in_period}'
|
||||
else:
|
||||
text += ' (не найден!)'
|
||||
text += '</i>'
|
||||
|
||||
# Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
|
||||
keyboard_rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'),
|
||||
],
|
||||
[types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')],
|
||||
[types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest'
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
# Кнопки действий (только если есть потерянные рефералы)
|
||||
if report.lost_referrals:
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
|
||||
)
|
||||
|
||||
keyboard_rows.extend(
|
||||
[
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при анализе логов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Показывает диагностику реферальной системы по логам."""
|
||||
# Определяем период из callback_data или используем "today" по умолчанию
|
||||
if ':' in callback.data:
|
||||
period = callback.data.split(':')[1]
|
||||
else:
|
||||
period = 'today'
|
||||
|
||||
await _show_diagnostics_for_period(callback, db, state, period)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Показывает предпросмотр исправлений потерянных рефералов."""
|
||||
try:
|
||||
await callback.answer('Анализирую...')
|
||||
|
||||
# Получаем период из state
|
||||
state_data = await state.get_data()
|
||||
period = state_data.get('diagnostics_period', 'today')
|
||||
|
||||
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
|
||||
|
||||
# Проверяем, работаем ли с загруженным файлом
|
||||
if period == 'uploaded_file':
|
||||
# Используем сохранённый отчёт из загруженного файла (десериализуем)
|
||||
report_data = state_data.get('uploaded_file_report')
|
||||
if not report_data:
|
||||
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
|
||||
return
|
||||
report = DiagnosticReport.from_dict(report_data)
|
||||
period_display = 'загруженный файл'
|
||||
else:
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
if not report.lost_referrals:
|
||||
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
|
||||
return
|
||||
|
||||
# Запускаем предпросмотр исправлений
|
||||
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
📋 <b>Предпросмотр исправлений — {period_display}</b>
|
||||
|
||||
<b>📊 Что будет сделано:</b>
|
||||
• Исправлено рефералов: {fix_report.users_fixed}
|
||||
• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
|
||||
• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
|
||||
• Ошибок: {fix_report.errors}
|
||||
|
||||
<b>🔍 Детали:</b>
|
||||
"""
|
||||
|
||||
# Показываем первые 10 деталей
|
||||
for i, detail in enumerate(fix_report.details[:10], 1):
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{detail.username}'
|
||||
|
||||
if detail.error:
|
||||
text += f'{i}. {user_name} — ❌ {detail.error}\n'
|
||||
else:
|
||||
text += f'{i}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
if detail.had_first_topup:
|
||||
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
|
||||
if detail.bonus_to_referrer_kopeks > 0:
|
||||
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
|
||||
|
||||
if len(fix_report.details) > 10:
|
||||
text += f'\n<i>... и ещё {len(fix_report.details) - 10}</i>\n'
|
||||
|
||||
text += '\n⚠️ <b>Внимание!</b> Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
|
||||
|
||||
# Кнопка назад зависит от источника
|
||||
back_button_text = '⬅️ К диагностике'
|
||||
back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
|
||||
[types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Применяет исправления потерянных рефералов."""
|
||||
try:
|
||||
await callback.answer('Применяю исправления...')
|
||||
|
||||
# Получаем период из state
|
||||
state_data = await state.get_data()
|
||||
period = state_data.get('diagnostics_period', 'today')
|
||||
|
||||
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
|
||||
|
||||
# Проверяем, работаем ли с загруженным файлом
|
||||
if period == 'uploaded_file':
|
||||
# Используем сохранённый отчёт из загруженного файла (десериализуем)
|
||||
report_data = state_data.get('uploaded_file_report')
|
||||
if not report_data:
|
||||
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
|
||||
return
|
||||
report = DiagnosticReport.from_dict(report_data)
|
||||
period_display = 'загруженный файл'
|
||||
else:
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
if not report.lost_referrals:
|
||||
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
|
||||
return
|
||||
|
||||
# Применяем исправления
|
||||
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
✅ <b>Исправления применены — {period_display}</b>
|
||||
|
||||
<b>📊 Результаты:</b>
|
||||
• Исправлено рефералов: {fix_report.users_fixed}
|
||||
• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
|
||||
• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
|
||||
• Ошибок: {fix_report.errors}
|
||||
|
||||
<b>🔍 Детали:</b>
|
||||
"""
|
||||
|
||||
# Показываем первые 10 успешных деталей
|
||||
success_count = 0
|
||||
for detail in fix_report.details:
|
||||
if not detail.error and success_count < 10:
|
||||
success_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{user_name}'
|
||||
|
||||
text += f'{success_count}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
|
||||
if detail.bonus_to_referrer_kopeks > 0:
|
||||
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
|
||||
|
||||
if fix_report.users_fixed > 10:
|
||||
text += f'\n<i>... и ещё {fix_report.users_fixed - 10} исправлений</i>\n'
|
||||
|
||||
# Показываем ошибки
|
||||
if fix_report.errors > 0:
|
||||
text += '\n<b>❌ Ошибки:</b>\n'
|
||||
error_count = 0
|
||||
for detail in fix_report.details:
|
||||
if detail.error and error_count < 5:
|
||||
error_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
text += f'• {user_name}: {detail.error}\n'
|
||||
if fix_report.errors > 5:
|
||||
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
|
||||
|
||||
# Кнопки зависят от источника
|
||||
keyboard_rows = []
|
||||
if period != 'uploaded_file':
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
|
||||
)
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
# Очищаем сохранённый отчёт из state
|
||||
if period == 'uploaded_file':
|
||||
await state.update_data(uploaded_file_report=None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при применении исправлений', show_alert=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Проверка бонусов по БД
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Проверяет по БД — всем ли рефералам начислены бонусы."""
|
||||
from app.services.referral_diagnostics_service import (
|
||||
referral_diagnostics_service,
|
||||
)
|
||||
|
||||
await callback.answer('🔍 Проверяю бонусы...')
|
||||
|
||||
try:
|
||||
report = await referral_diagnostics_service.check_missing_bonuses(db)
|
||||
|
||||
# Сохраняем отчёт в state для последующего применения
|
||||
await state.update_data(missing_bonuses_report=report.to_dict())
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Проверка бонусов по БД</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего рефералов: {report.total_referrals_checked}
|
||||
• С пополнением ≥ минимума: {report.referrals_with_topup}
|
||||
• <b>Без бонусов: {len(report.missing_bonuses)}</b>
|
||||
"""
|
||||
|
||||
if report.missing_bonuses:
|
||||
text += f"""
|
||||
💰 <b>Требуется начислить:</b>
|
||||
• Рефералам: {report.total_missing_to_referrals / 100:.0f}₽
|
||||
• Рефереерам: {report.total_missing_to_referrers / 100:.0f}₽
|
||||
• <b>Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}₽</b>
|
||||
|
||||
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
|
||||
"""
|
||||
for i, mb in enumerate(report.missing_bonuses[:15], 1):
|
||||
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
|
||||
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
|
||||
text += f'\n{i}. <b>{referral_name}</b>'
|
||||
text += f'\n └ Пригласил: {referrer_name}'
|
||||
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽'
|
||||
text += f'\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}₽'
|
||||
|
||||
if len(report.missing_bonuses) > 15:
|
||||
text += f'\n\n<i>... и ещё {len(report.missing_bonuses) - 15} чел.</i>'
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
else:
|
||||
text += '\n✅ <b>Все бонусы начислены!</b>'
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при проверке бонусов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Применяет начисление пропущенных бонусов."""
|
||||
from app.services.referral_diagnostics_service import (
|
||||
MissingBonusReport,
|
||||
referral_diagnostics_service,
|
||||
)
|
||||
|
||||
await callback.answer('💰 Начисляю бонусы...')
|
||||
|
||||
try:
|
||||
# Получаем сохранённый отчёт
|
||||
data = await state.get_data()
|
||||
report_dict = data.get('missing_bonuses_report')
|
||||
|
||||
if not report_dict:
|
||||
await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
|
||||
return
|
||||
|
||||
report = MissingBonusReport.from_dict(report_dict)
|
||||
|
||||
if not report.missing_bonuses:
|
||||
await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
|
||||
return
|
||||
|
||||
# Применяем исправления
|
||||
fix_report = await referral_diagnostics_service.fix_missing_bonuses(db, report.missing_bonuses, apply=True)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Бонусы начислены!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Обработано: {fix_report.users_fixed} пользователей
|
||||
• Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}₽
|
||||
• Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}₽
|
||||
• <b>Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}₽</b>
|
||||
"""
|
||||
|
||||
if fix_report.errors > 0:
|
||||
text += f'\n⚠️ Ошибок: {fix_report.errors}'
|
||||
|
||||
# Очищаем отчёт из state
|
||||
await state.update_data(missing_bonuses_report=None)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при начислении бонусов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_referrals_with_contest(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
"""Синхронизирует всех рефералов с активными конкурсами."""
|
||||
from app.database.crud.referral_contest import get_contests_for_events
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await callback.answer('🏆 Синхронизирую с конкурсами...')
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
# Получаем активные конкурсы
|
||||
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
|
||||
reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
|
||||
|
||||
all_contests = list(paid_contests) + list(reg_contests)
|
||||
|
||||
if not all_contests:
|
||||
await callback.message.edit_text(
|
||||
'❌ <b>Нет активных конкурсов рефералов</b>\n\n'
|
||||
'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Синхронизируем каждый конкурс
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
total_skipped = 0
|
||||
contest_results = []
|
||||
|
||||
for contest in all_contests:
|
||||
stats = await referral_contest_service.sync_contest(db, contest.id)
|
||||
if 'error' not in stats:
|
||||
total_created += stats.get('created', 0)
|
||||
total_updated += stats.get('updated', 0)
|
||||
total_skipped += stats.get('skipped', 0)
|
||||
contest_results.append(f'• {contest.title}: +{stats.get("created", 0)} новых')
|
||||
else:
|
||||
contest_results.append(f'• {contest.title}: ошибка')
|
||||
|
||||
text = f"""
|
||||
🏆 <b>Синхронизация с конкурсами завершена!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Конкурсов обработано: {len(all_contests)}
|
||||
• Новых событий добавлено: {total_created}
|
||||
• Обновлено: {total_updated}
|
||||
• Пропущено (уже есть): {total_skipped}
|
||||
|
||||
📋 <b>По конкурсам:</b>
|
||||
"""
|
||||
text += '\n'.join(contest_results)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при синхронизации', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def request_log_file_upload(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Запрашивает загрузку лог-файла для анализа."""
|
||||
await state.set_state(AdminStates.waiting_for_log_file)
|
||||
|
||||
text = """
|
||||
📤 <b>Загрузка лог-файла для анализа</b>
|
||||
|
||||
Отправьте файл лога (расширение .log или .txt).
|
||||
|
||||
Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
|
||||
|
||||
⚠️ <b>Важно:</b>
|
||||
• Файл должен быть текстовым (.log, .txt)
|
||||
• Максимальный размер: 50 MB
|
||||
• После анализа файл будет автоматически удалён
|
||||
|
||||
Если ротация логов удалила старые данные — загрузите резервную копию.
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Получает и анализирует загруженный лог-файл."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
'❌ Пожалуйста, отправьте файл документом.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем расширение файла
|
||||
file_name = message.document.file_name or 'unknown'
|
||||
file_ext = Path(file_name).suffix.lower()
|
||||
|
||||
if file_ext not in ['.log', '.txt']:
|
||||
await message.answer(
|
||||
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем размер файла
|
||||
max_size = 50 * 1024 * 1024 # 50 MB
|
||||
if message.document.file_size > max_size:
|
||||
await message.answer(
|
||||
f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\nМаксимальный размер: 50 MB',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Информируем о начале загрузки
|
||||
status_message = await message.answer(
|
||||
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
)
|
||||
|
||||
temp_file_path = None
|
||||
|
||||
try:
|
||||
# Скачиваем файл во временную директорию
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = str(Path(temp_dir) / f'ref_diagnostics_{message.from_user.id}_{file_name}')
|
||||
|
||||
# Скачиваем файл
|
||||
file = await message.bot.get_file(message.document.file_id)
|
||||
await message.bot.download_file(file.file_path, temp_file_path)
|
||||
|
||||
logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
|
||||
|
||||
# Обновляем статус
|
||||
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
|
||||
|
||||
# Анализируем файл
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
|
||||
report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
🔍 <b>Анализ лог-файла: {file_name}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
• Уникальных пользователей: {report.unique_users_clicked}
|
||||
• Потерянных рефералов: {len(report.lost_referrals)}
|
||||
• Строк в файле: {report.lines_in_period}
|
||||
"""
|
||||
|
||||
if report.lost_referrals:
|
||||
text += '\n<b>❌ Потерянные рефералы:</b>\n'
|
||||
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
|
||||
|
||||
for i, lost in enumerate(report.lost_referrals[:15], 1):
|
||||
# Статус пользователя
|
||||
if not lost.registered:
|
||||
status = '⚠️ Не в БД'
|
||||
elif not lost.has_referrer:
|
||||
status = '❌ Без реферера'
|
||||
else:
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
# Время
|
||||
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
else:
|
||||
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
|
||||
|
||||
# Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
|
||||
await state.update_data(
|
||||
diagnostics_period='uploaded_file',
|
||||
uploaded_file_report=report.to_dict(),
|
||||
)
|
||||
|
||||
# Кнопки действий
|
||||
keyboard_rows = []
|
||||
|
||||
if report.lost_referrals:
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
|
||||
)
|
||||
|
||||
keyboard_rows.extend(
|
||||
[
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
# Удаляем статусное сообщение
|
||||
await status_message.delete()
|
||||
|
||||
# Отправляем результат
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
# Очищаем состояние
|
||||
await state.set_state(AdminStates.referral_diagnostics_period)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
|
||||
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
f'❌ <b>Ошибка при анализе файла</b>\n\n'
|
||||
f'Файл: {file_name}\n'
|
||||
f'Ошибка: {e!s}\n\n'
|
||||
f'Проверьте, что файл является текстовым логом бота.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload'
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ К диагностике', callback_data='admin_referral_diagnostics'
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
except:
|
||||
await message.answer(
|
||||
f'❌ Ошибка при анализе файла: {e!s}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Удаляем временный файл
|
||||
if temp_file_path and Path(temp_file_path).exists():
|
||||
try:
|
||||
Path(temp_file_path).unlink()
|
||||
logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления временного файла: {e}')
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals')
|
||||
dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top')
|
||||
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:'))
|
||||
dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings')
|
||||
dp.callback_query.register(show_referral_diagnostics, F.data == 'admin_referral_diagnostics')
|
||||
dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
|
||||
dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
|
||||
dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
|
||||
|
||||
# Загрузка лог-файла
|
||||
dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
|
||||
dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
|
||||
|
||||
# Проверка бонусов по БД
|
||||
dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
|
||||
dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
|
||||
dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
|
||||
|
||||
# Хендлеры заявок на вывод
|
||||
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
|
||||
|
||||
@@ -1993,6 +1993,15 @@ async def process_edit_traffic_topup_packages(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
'Пожалуйста, отправьте текстовое сообщение.\n\n'
|
||||
'Формат: <code>ГБ:цена_в_копейках</code>\n'
|
||||
'Пример: <code>5:5000, 10:9000, 20:15000</code>',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
packages = _parse_traffic_topup_packages(message.text.strip())
|
||||
|
||||
if not packages:
|
||||
|
||||
@@ -1045,10 +1045,11 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
|
||||
return
|
||||
|
||||
if not getattr(user, 'telegram_id', None):
|
||||
logger.error(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s)',
|
||||
logger.warning(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)',
|
||||
ticket.id,
|
||||
getattr(user, 'username', None),
|
||||
getattr(user, 'auth_type', None),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
+137
-188
@@ -65,11 +65,8 @@ class UserFilterType(Enum):
|
||||
"""Типы фильтрации пользователей."""
|
||||
|
||||
BALANCE = 'balance'
|
||||
TRAFFIC = 'traffic'
|
||||
ACTIVITY = 'activity'
|
||||
SPENDING = 'spending'
|
||||
PURCHASES = 'purchases'
|
||||
CAMPAIGN = 'campaign'
|
||||
POTENTIAL_CUSTOMERS = 'potential_customers'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
|
||||
pagination_prefix='admin_users_balance_list',
|
||||
order_param='order_by_balance',
|
||||
),
|
||||
UserFilterType.TRAFFIC: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_traffic_list,
|
||||
title='👥 <b>Список пользователей по использованному трафику</b>',
|
||||
empty_message='📶 Пользователи с трафиком не найдены',
|
||||
pagination_prefix='admin_users_traffic_list',
|
||||
order_param='order_by_traffic',
|
||||
),
|
||||
UserFilterType.ACTIVITY: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_last_activity_list,
|
||||
title='👥 <b>Пользователи по активности</b>',
|
||||
empty_message='🕒 Пользователи с активностью не найдены',
|
||||
pagination_prefix='admin_users_activity_list',
|
||||
order_param='order_by_last_activity',
|
||||
),
|
||||
UserFilterType.SPENDING: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_spending_list,
|
||||
title='👥 <b>Пользователи по сумме трат</b>',
|
||||
empty_message='💳 Пользователи с тратами не найдены',
|
||||
pagination_prefix='admin_users_spending_list',
|
||||
order_param='order_by_total_spent',
|
||||
),
|
||||
UserFilterType.PURCHASES: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_purchases_list,
|
||||
title='👥 <b>Пользователи по количеству покупок</b>',
|
||||
empty_message='🛒 Пользователи с покупками не найдены',
|
||||
pagination_prefix='admin_users_purchases_list',
|
||||
order_param='order_by_purchase_count',
|
||||
),
|
||||
UserFilterType.CAMPAIGN: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_campaign_list,
|
||||
title='👥 <b>Пользователи по кампании регистрации</b>',
|
||||
@@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
|
||||
pagination_prefix='admin_users_campaign_list',
|
||||
order_param='', # использует специальный метод
|
||||
),
|
||||
UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_potential_customers_list,
|
||||
title='👥 <b>Потенциальные клиенты</b>',
|
||||
empty_message='💰 Потенциальные клиенты не найдены',
|
||||
pagination_prefix='admin_users_potential_customers_list',
|
||||
order_param='', # использует специальный метод
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -173,34 +149,6 @@ def _build_user_button_text(
|
||||
days_left = (user.subscription.end_date - datetime.utcnow()).days
|
||||
button_text += f' | 📅 {days_left}д'
|
||||
|
||||
elif filter_type == UserFilterType.TRAFFIC:
|
||||
if user.subscription:
|
||||
sub = user.subscription
|
||||
used = sub.traffic_used_gb or 0.0
|
||||
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
|
||||
limit_display = f'{sub.traffic_limit_gb}'
|
||||
else:
|
||||
limit_display = '♾️'
|
||||
traffic_display = f'{used:.1f}/{limit_display} ГБ'
|
||||
else:
|
||||
traffic_display = 'нет подписки'
|
||||
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}'
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
|
||||
elif filter_type == UserFilterType.ACTIVITY:
|
||||
activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно'
|
||||
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}'
|
||||
|
||||
elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
|
||||
stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {}
|
||||
total_spent = stats.get('total_spent', 0)
|
||||
purchases = stats.get('purchase_count', 0)
|
||||
if filter_type == UserFilterType.SPENDING:
|
||||
button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}'
|
||||
else:
|
||||
button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}'
|
||||
|
||||
elif filter_type == UserFilterType.CAMPAIGN:
|
||||
info = extra_data.get(user.id, {}) if extra_data else {}
|
||||
campaign_name = info.get('campaign_name') or 'Без кампании'
|
||||
@@ -219,18 +167,6 @@ def _build_user_button_text(
|
||||
button_text = f'{status_emoji} {sub_emoji} {short_name}'
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
elif filter_type == UserFilterType.TRAFFIC:
|
||||
if user.subscription:
|
||||
sub = user.subscription
|
||||
used = sub.traffic_used_gb or 0.0
|
||||
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
|
||||
limit_display = f'{sub.traffic_limit_gb}'
|
||||
else:
|
||||
limit_display = '♾️'
|
||||
traffic_display = f'{used:.1f}/{limit_display} ГБ'
|
||||
else:
|
||||
traffic_display = 'нет'
|
||||
button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}'
|
||||
else:
|
||||
button_text = f'{status_emoji} {short_name}'
|
||||
|
||||
@@ -280,10 +216,6 @@ async def _show_users_list_filtered(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Для spending/purchases нужны дополнительные данные
|
||||
if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
|
||||
extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users])
|
||||
|
||||
# Формируем текст заголовка
|
||||
text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n'
|
||||
text += 'Нажмите на пользователя для управления:'
|
||||
@@ -576,38 +508,122 @@ async def show_users_ready_to_renew(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_traffic(
|
||||
async def show_potential_customers(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по использованному трафику (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page)
|
||||
"""Показывает пользователей без активной подписки с балансом >= месячной цены."""
|
||||
await state.set_state(AdminStates.viewing_user_from_potential_customers_list)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_last_activity(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по последней активности."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page)
|
||||
monthly_price = PERIOD_PRICES.get(30, 99000)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_potential_customers(
|
||||
db,
|
||||
min_balance_kopeks=monthly_price,
|
||||
page=page,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_spending(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по сумме трат (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page)
|
||||
amount_text = settings.format_price(monthly_price)
|
||||
header = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE',
|
||||
'💰 Потенциальные клиенты',
|
||||
)
|
||||
description = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC',
|
||||
'Пользователи без активной подписки с балансом {amount} или больше.',
|
||||
).format(amount=amount_text)
|
||||
|
||||
if not users_data['users']:
|
||||
empty_text = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY',
|
||||
'Сейчас нет пользователей, которые подходят под этот фильтр.',
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
f'{header}\n\n{description}\n\n{empty_text}',
|
||||
reply_markup=get_admin_users_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_purchases(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по количеству покупок (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page)
|
||||
text = f'{header}\n\n{description}\n\n'
|
||||
text += 'Нажмите на пользователя для управления:'
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data['users']:
|
||||
subscription = user.subscription
|
||||
status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫'
|
||||
subscription_emoji = '❌'
|
||||
|
||||
if subscription:
|
||||
if subscription.is_trial:
|
||||
subscription_emoji = '🎁'
|
||||
elif subscription.is_active:
|
||||
subscription_emoji = '💎'
|
||||
else:
|
||||
subscription_emoji = '⏰'
|
||||
|
||||
button_text = (
|
||||
f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
)
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + '...'
|
||||
button_text = (
|
||||
f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f'admin_user_manage_{user.id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if users_data['total_pages'] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data['current_page'],
|
||||
users_data['total_pages'],
|
||||
'admin_users_potential_customers_list',
|
||||
'admin_users_potential_customers_filter',
|
||||
db_user.language,
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔍 Поиск',
|
||||
callback_data='admin_users_search',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='📊 Статистика',
|
||||
callback_data='admin_users_stats',
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ Назад',
|
||||
callback_data='admin_users',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination(
|
||||
await show_users_list_by_balance(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_traffic_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_activity_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_spending_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_spending(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_spending(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_purchases_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_ready_to_renew_pagination(
|
||||
@@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination(
|
||||
await show_users_ready_to_renew(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_potential_customers_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_potential_customers(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_potential_customers(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_campaign_list_pagination(
|
||||
@@ -1310,18 +1283,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
back_callback = 'admin_users_balance_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_traffic_list:
|
||||
back_callback = 'admin_users_traffic_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_last_activity_list:
|
||||
back_callback = 'admin_users_activity_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_spending_list:
|
||||
back_callback = 'admin_users_spending_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_purchases_list:
|
||||
back_callback = 'admin_users_purchases_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_campaign_list:
|
||||
back_callback = 'admin_users_campaign_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
|
||||
back_callback = 'admin_users_ready_to_renew_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_potential_customers_list:
|
||||
back_callback = 'admin_users_potential_customers_filter'
|
||||
|
||||
# Базовая клавиатура профиля
|
||||
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
@@ -5483,26 +5450,14 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_')
|
||||
)
|
||||
@@ -5659,14 +5614,8 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter')
|
||||
|
||||
dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter')
|
||||
|
||||
dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter')
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -126,23 +125,6 @@ async def process_cryptobot_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.database.models import User
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -94,23 +93,6 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -171,23 +170,6 @@ async def process_yookassa_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
@@ -338,23 +320,6 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
|
||||
+11
-11
@@ -153,7 +153,7 @@ async def show_main_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -243,7 +243,7 @@ async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
|
||||
async def show_service_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -277,7 +277,7 @@ async def show_info_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -320,7 +320,7 @@ async def show_promo_groups_info(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -468,7 +468,7 @@ async def show_faq_pages(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -525,7 +525,7 @@ async def show_faq_page(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -662,7 +662,7 @@ async def show_privacy_policy(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -779,7 +779,7 @@ async def show_public_offer(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -896,7 +896,7 @@ async def show_language_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -938,7 +938,7 @@ async def process_language_change(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -1000,7 +1000,7 @@ async def process_language_change(
|
||||
async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
|
||||
@@ -9,7 +9,6 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.states import PromoCodeStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -71,23 +70,6 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Активация промокода невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
+60
-60
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -35,7 +35,6 @@ from app.middlewares.channel_checker import (
|
||||
get_pending_payload_from_redis,
|
||||
)
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.pinned_message_service import (
|
||||
@@ -309,24 +308,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
|
||||
|
||||
data = await state.get_data() or {}
|
||||
had_pending_payload = 'pending_start_payload' in data
|
||||
pending_start_payload = data.pop('pending_start_payload', None)
|
||||
had_campaign_notification_flag = 'campaign_notification_sent' in data
|
||||
campaign_notification_sent = data.pop('campaign_notification_sent', False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
|
||||
# pending_start_payload обрабатывается отдельно ниже
|
||||
campaign_notification_sent = data.get('campaign_notification_sent', False)
|
||||
state_needs_update = False
|
||||
|
||||
# Получаем payload из state или Redis
|
||||
pending_start_payload = data.get('pending_start_payload', None)
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
data['pending_start_payload'] = redis_payload
|
||||
state_needs_update = True
|
||||
logger.info(
|
||||
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
)
|
||||
# Очищаем Redis после получения
|
||||
await delete_pending_payload_from_redis(message.from_user.id)
|
||||
# НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
@@ -774,12 +776,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except:
|
||||
pass
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass # Сообщение уже содержит нужный текст
|
||||
else:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
|
||||
logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}')
|
||||
|
||||
@@ -990,25 +991,6 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
|
||||
async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
|
||||
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}')
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f'🚫 Регистрация невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
@@ -1167,6 +1149,12 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
|
||||
await delete_pending_payload_from_redis(callback.from_user.id)
|
||||
logger.info(
|
||||
'🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -1253,25 +1241,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession):
|
||||
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}')
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Регистрация невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
@@ -1454,6 +1423,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
|
||||
await delete_pending_payload_from_redis(message.from_user.id)
|
||||
logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -1725,6 +1698,7 @@ async def required_sub_channel_check(
|
||||
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
state_data['pending_start_payload'] = redis_payload
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
@@ -1780,16 +1754,31 @@ async def required_sub_channel_check(
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data['campaign_id'] = campaign.id
|
||||
logger.info(
|
||||
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
|
||||
campaign.id,
|
||||
# Обрабатываем payload только если ещё не обработан
|
||||
# (проверяем по наличию referral_code или campaign_id в state)
|
||||
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data['campaign_id'] = campaign.id
|
||||
logger.info(
|
||||
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
|
||||
pending_start_payload,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
|
||||
'✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
|
||||
state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
|
||||
)
|
||||
|
||||
await state.set_data(state_data)
|
||||
@@ -1829,6 +1818,12 @@ async def required_sub_channel_check(
|
||||
except Exception as e:
|
||||
logger.warning(f'Не удалось удалить сообщение: {e}')
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
|
||||
# и перед показом главного меню или завершением регистрации
|
||||
if pending_start_payload:
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
@@ -1911,6 +1906,11 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
|
||||
state_data.pop('pending_start_payload', None)
|
||||
await state.set_data(state_data)
|
||||
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
|
||||
|
||||
# Обрабатываем реферальную регистрацию
|
||||
if referrer_id:
|
||||
try:
|
||||
|
||||
@@ -39,6 +39,21 @@ async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
# Суточные подписки имеют свой механизм продления, глобальный autopay не применяется
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
|
||||
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
status = (
|
||||
texts.t('AUTOPAY_STATUS_ENABLED', 'включен')
|
||||
if subscription.autopay_enabled
|
||||
@@ -68,6 +83,24 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
subscription = db_user.subscription
|
||||
enable = callback.data == 'autopay_enable'
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
if enable:
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
|
||||
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await update_subscription_autopay(db, subscription, enable)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -468,6 +468,10 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
|
||||
if 'period_days' not in data:
|
||||
await callback.answer('❌ Данные подписки устарели. Начните оформление заново.', show_alert=True)
|
||||
return
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
if country_uuid in selected_countries:
|
||||
selected_countries.remove(country_uuid)
|
||||
|
||||
@@ -186,18 +186,29 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
|
||||
if tariff:
|
||||
price_per_device = tariff_device_price
|
||||
price_text = texts.format_price(price_per_device)
|
||||
tariff_min_devices = getattr(tariff, 'device_limit', 1) or 1
|
||||
|
||||
# Добавляем информацию о минимальном лимите если он больше 1
|
||||
min_devices_info = ''
|
||||
if tariff_min_devices > 1:
|
||||
min_devices_info = texts.t(
|
||||
'CHANGE_DEVICES_MIN_LIMIT_INFO',
|
||||
'\nМинимум для тарифа: {min_devices} устройств\n',
|
||||
).format(min_devices=tariff_min_devices)
|
||||
|
||||
prompt_text = texts.t(
|
||||
'CHANGE_DEVICES_PROMPT_TARIFF',
|
||||
(
|
||||
'📱 <b>Изменение количества устройств</b>\n\n'
|
||||
'Текущий лимит: {current_devices} устройств\n'
|
||||
'Цена за доп. устройство: {price}/мес\n'
|
||||
'{min_devices_info}'
|
||||
'Выберите новое количество устройств:\n\n'
|
||||
'💡 <b>Важно:</b>\n'
|
||||
'• При увеличении - доплата пропорционально оставшемуся времени\n'
|
||||
'• При уменьшении - возврат средств не производится'
|
||||
),
|
||||
).format(current_devices=current_devices, price=price_text)
|
||||
).format(current_devices=current_devices, price=price_text, min_devices_info=min_devices_info)
|
||||
else:
|
||||
prompt_text = texts.t(
|
||||
'CHANGE_DEVICES_PROMPT',
|
||||
@@ -276,6 +287,18 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем минимальное количество устройств на тарифе
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
if new_devices_count < tariff_min_devices:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'DEVICES_MIN_LIMIT_REACHED',
|
||||
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
|
||||
).format(limit=tariff_min_devices),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
devices_difference = new_devices_count - current_devices
|
||||
|
||||
if devices_difference > 0:
|
||||
@@ -473,13 +496,41 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
# Проверяем тариф подписки
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Для тарифов - проверяем разрешено ли изменение устройств
|
||||
if tariff:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price is None or tariff_device_price <= 0:
|
||||
await callback.answer(
|
||||
texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
elif not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем минимальное количество устройств на тарифе
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
if new_devices_count < tariff_min_devices:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'DEVICES_MIN_LIMIT_REACHED',
|
||||
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
|
||||
).format(limit=tariff_min_devices),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -510,7 +561,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# При уменьшении лимита - сбросить лишние устройства
|
||||
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
@@ -521,16 +572,34 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
devices_list = response['response'].get('devices', [])
|
||||
connected_count = len(devices_list)
|
||||
|
||||
# Если подключённых устройств больше чем новый лимит - сбросить все
|
||||
# Если подключённых устройств больше чем новый лимит - удалить лишние
|
||||
if connected_count > new_devices_count:
|
||||
devices_to_remove = connected_count - new_devices_count
|
||||
logger.info(
|
||||
f'🔧 Сброс устройств при уменьшении лимита: '
|
||||
f'подключено {connected_count}, новый лимит {new_devices_count}'
|
||||
f'🔧 Удаление лишних устройств при уменьшении лимита: '
|
||||
f'подключено {connected_count}, новый лимит {new_devices_count}, '
|
||||
f'удаляем {devices_to_remove}'
|
||||
)
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
devices_reset_count = connected_count
|
||||
|
||||
# Сортируем по дате (последние в конце) и удаляем последние
|
||||
sorted_devices = sorted(
|
||||
devices_list,
|
||||
key=lambda d: d.get('updatedAt') or d.get('createdAt') or '',
|
||||
)
|
||||
devices_to_delete = sorted_devices[-devices_to_remove:]
|
||||
|
||||
for device in devices_to_delete:
|
||||
device_hwid = device.get('hwid')
|
||||
if device_hwid:
|
||||
try:
|
||||
delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid}
|
||||
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
devices_reset_count += 1
|
||||
logger.info(f'✅ Удалено устройство {device_hwid}')
|
||||
except Exception as del_error:
|
||||
logger.error(f'Ошибка удаления устройства {device_hwid}: {del_error}')
|
||||
except Exception as reset_error:
|
||||
logger.error(f'Ошибка сброса устройств при уменьшении лимита: {reset_error}')
|
||||
logger.error(f'Ошибка удаления устройств при уменьшении лимита: {reset_error}')
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
@@ -570,9 +639,9 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
).format(old=current_devices, new=new_devices_count)
|
||||
if devices_reset_count > 0:
|
||||
success_text += texts.t(
|
||||
'DEVICE_CHANGE_DEVICES_RESET',
|
||||
'\n🔄 Сброшено устройств: {count}\n💡 Подключите заново нужные устройства (до {limit} шт.)\n\n',
|
||||
).format(count=devices_reset_count, limit=new_devices_count)
|
||||
'DEVICE_CHANGE_DEVICES_REMOVED',
|
||||
'\n🗑 Удалено устройств: {count}\n',
|
||||
).format(count=devices_reset_count)
|
||||
success_text += texts.t(
|
||||
'DEVICE_CHANGE_NO_REFUND_INFO',
|
||||
'ℹ️ Возврат средств не производится',
|
||||
@@ -1121,6 +1190,18 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о докупке устройств
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
old_device_limit = subscription.device_limit - devices_count
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о докупке устройств: {e}')
|
||||
|
||||
success_text = (
|
||||
'✅ Устройства успешно добавлены!\n\n'
|
||||
f'📱 Добавлено: {devices_count} устройств\n'
|
||||
|
||||
@@ -24,6 +24,10 @@ async def _prepare_subscription_summary(
|
||||
texts,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
summary_data = dict(data)
|
||||
|
||||
if 'period_days' not in summary_data:
|
||||
raise KeyError('period_days missing from subscription data — FSM state likely expired')
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||||
|
||||
@@ -39,7 +39,6 @@ from app.keyboards.inline import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
@@ -561,6 +560,15 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -753,6 +761,15 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -1385,6 +1402,11 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
prepared_cart_data = dict(cart_data)
|
||||
|
||||
if 'period_days' not in prepared_cart_data:
|
||||
await callback.answer('❌ Корзина повреждена. Оформите подписку заново.', show_alert=True)
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
try:
|
||||
from .pricing import _prepare_subscription_summary
|
||||
@@ -1724,24 +1746,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
|
||||
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Продление подписки невозможно\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -2228,24 +2232,6 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
|
||||
|
||||
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Покупка подписки невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
@@ -4134,24 +4120,6 @@ async def handle_simple_subscription_purchase(
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает простую покупку подписки."""
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Простая покупка подписки невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
|
||||
@@ -2242,6 +2242,19 @@ async def confirm_tariff_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при смене тарифа для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
db,
|
||||
@@ -2262,6 +2275,7 @@ async def confirm_tariff_switch(
|
||||
days_for_new_tariff, # Итоговый срок подписки
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=final_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
@@ -2395,7 +2409,7 @@ async def confirm_daily_tariff_switch(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
# Обновляем пользователя в Remnawave (create_remnawave_user также сбрасывает устройства)
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
@@ -2407,6 +2421,19 @@ async def confirm_daily_tariff_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при смене на суточный тариф для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
db,
|
||||
@@ -2427,6 +2454,7 @@ async def confirm_daily_tariff_switch(
|
||||
1, # 1 день
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
@@ -2972,6 +3000,19 @@ async def confirm_instant_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при переключении тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию если была оплата
|
||||
if is_upgrade and upgrade_cost > 0:
|
||||
await create_transaction(
|
||||
@@ -2993,6 +3034,7 @@ async def confirm_instant_switch(
|
||||
remaining_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=upgrade_cost,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
|
||||
@@ -484,7 +484,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
discount_per_month = discount_result['discount']
|
||||
charged_months = 1
|
||||
|
||||
if subscription:
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
# Пропорциональный расчёт применяем только в классическом режиме.
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription and subscription.tariff_id
|
||||
|
||||
if is_tariff_mode:
|
||||
price = discounted_per_month
|
||||
elif subscription:
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
@@ -540,6 +547,9 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Сохраняем старое значение трафика для уведомления
|
||||
old_traffic_limit = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
@@ -580,6 +590,17 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о докупке трафика
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'traffic', old_traffic_limit, subscription.traffic_limit_gb, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о докупке трафика: {e}')
|
||||
|
||||
success_text = '✅ Трафик успешно добавлен!\n\n'
|
||||
if traffic_gb == 0:
|
||||
success_text += '🎉 Теперь у вас безлимитный трафик!'
|
||||
|
||||
@@ -80,6 +80,9 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
return
|
||||
|
||||
"""Обработать ввод заголовка тикета"""
|
||||
if not message.text:
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
return
|
||||
title = message.text.strip()
|
||||
|
||||
data_prompt = await state.get_data()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram import Bot, types
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
@@ -13,6 +13,15 @@ from app.external.tribute import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальная ссылка на бота для отправки уведомлений
|
||||
_bot_instance: Bot | None = None
|
||||
|
||||
|
||||
def set_webhook_bot(bot: Bot) -> None:
|
||||
"""Устанавливает экземпляр бота для отправки уведомлений об ошибках в webhook."""
|
||||
global _bot_instance
|
||||
_bot_instance = bot
|
||||
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
|
||||
+12
-24
@@ -326,6 +326,12 @@ def get_admin_users_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
|
||||
text=_t(texts, 'ADMIN_USERS_BULK_BAN', '🛑 Массовый бан'), callback_data='admin_bulk_ban_start'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_BLOCKED_CHECK', '🔒 Заблокировавшие бота'),
|
||||
callback_data='admin_blocked_users',
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')],
|
||||
]
|
||||
)
|
||||
@@ -342,36 +348,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark
|
||||
callback_data='admin_users_balance_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'),
|
||||
callback_data='admin_users_traffic_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'),
|
||||
callback_data='admin_users_activity_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'),
|
||||
callback_data='admin_users_spending_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'),
|
||||
callback_data='admin_users_purchases_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'),
|
||||
callback_data='admin_users_ready_to_renew_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'),
|
||||
callback_data='admin_users_potential_customers_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'),
|
||||
|
||||
+15
-18
@@ -1860,17 +1860,8 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
subscription_end_date: Дата окончания подписки для расчета цены
|
||||
discount_percent: Процент скидки
|
||||
"""
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
|
||||
if not packages:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -1889,21 +1880,23 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
# Сортируем пакеты по размеру
|
||||
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
|
||||
|
||||
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
|
||||
for gb, price_per_month in sorted_packages:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
discounted_price, discount_value = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
period_text = ' /мес' if language == 'ru' else ' /mo'
|
||||
|
||||
if language == 'ru':
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
text = f'📊 +{gb} GB traffic - {discounted_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if discount_percent > 0 and discount_value > 0:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1966,7 +1959,11 @@ def get_change_devices_keyboard(
|
||||
else:
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
# Минимальное количество устройств: device_limit тарифа или 1
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
min_devices = max(1, tariff_min_devices)
|
||||
|
||||
start_range = max(min_devices, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
|
||||
for devices_count in range(start_range, end_range):
|
||||
@@ -2065,7 +2062,7 @@ def get_reset_traffic_confirm_keyboard(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'),
|
||||
callback_data=f'topup_amount_{missing_kopeks}',
|
||||
callback_data='balance_topup',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -925,6 +925,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nPrice per extra device: {price}/mo\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
@@ -977,7 +978,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {period})",
|
||||
"DEVICE_CHANGE_FREE": "Free",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
@@ -1652,6 +1653,7 @@
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
|
||||
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Blocked bot users",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Game starts",
|
||||
"CONTEST_PRIZE": "Prize",
|
||||
"DAYS": "days subscription",
|
||||
|
||||
@@ -807,6 +807,10 @@
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
|
||||
@@ -941,6 +945,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nЦена за доп. устройство: {price}/мес\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
@@ -994,7 +999,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Бесплатно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
@@ -1534,7 +1539,7 @@
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Пополнить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
@@ -1669,6 +1674,7 @@
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
|
||||
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Заблокировавшие бота",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
|
||||
"CONTEST_PRIZE": "Приз",
|
||||
"DAYS": "дн. подписки",
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Підтвердження зміни</b>\n\n Поточна кількість: {current_devices} пристроїв\n Нова кількість: {new_devices} пристроїв\n\n Дія: {action}\n 💰 {cost}\n\n Підтвердити зміну?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Зміна кількості пристроїв</b>\n\n Поточний ліміт: {current_devices} пристроїв\n\n Оберіть нову кількість пристроїв:\n\n 💡 <b>Важливо:</b>\n • При збільшенні - доплата пропорційно часу, що залишився\n • При зменшенні - повернення коштів не здійснюється\n ",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nЦіна за дод. пристрій: {price}/міс\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Кількість пристроїв зменшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n ℹ️ Повернення коштів не здійснюється\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Кількість пристроїв збільшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
|
||||
@@ -917,7 +918,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Підтвердження зміни</b>\n\nПоточна кількість: {current} пристроїв\nНова кількість: {new} пристроїв\n\nДія: {action}\n💰 {cost}\n\nПідтвердити зміну?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Кількість пристроїв зменшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (знижка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} міс)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Безкоштовно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Кількість пристроїв збільшено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Повернення коштів не здійснюється",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM":"\n 📱<b>确认更改</b>\n\n 当前数量:{current_devices}台设备\n 新数量:{new_devices}台设备\n\n 操作:{action}\n 💰{cost}\n\n 确认更改吗?\n ",
|
||||
"CHANGE_DEVICES_INFO":"\n 📱<b>更改设备数量</b>\n\n 当前限制:{current_devices}台设备\n\n 请选择新的设备数量:\n\n 💡<b>重要提示:</b>\n •增加-按剩余时间比例补差价\n •减少-不退款\n ",
|
||||
"CHANGE_DEVICES_PROMPT":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n每台额外设备价格:{price}/月\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE":"\n ✅设备数量已减少!\n\n 📱之前:{old_count}→现在:{new_count}\n ℹ️不退款\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n ✅设备数量已增加!\n\n 📱之前:{old_count}→现在:{new_count}\n 💰已扣除:{amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
|
||||
@@ -915,7 +916,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION":"📱<b>确认更改</b>\n\n当前数量:{current}台设备\n新数量:{new}台设备\n\n操作:{action}\n💰{cost}\n\n确认更改吗?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS":"✅设备数量已减少!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO":"(折扣{percent}%:-{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}(共{months}个月)",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}({period})",
|
||||
"DEVICE_CHANGE_FREE":"免费",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS":"✅设备数量已增加!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND":"不退款",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram.
|
||||
|
||||
Перехватывает все log records уровня ERROR и CRITICAL и отправляет их
|
||||
в админский чат через существующий механизм send_error_to_admin_chat()
|
||||
из app.middlewares.global_error.
|
||||
|
||||
Дедупликация:
|
||||
- Записи, уже обработанные GlobalErrorMiddleware или @error_handler,
|
||||
помечаются атрибутом _admin_notified = True и пропускаются.
|
||||
- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения
|
||||
дублирования одинаковых ошибок за короткий период.
|
||||
|
||||
Async bridge:
|
||||
- logging.Handler.emit() -- синхронный. Мы используем
|
||||
asyncio.get_running_loop().call_soon_threadsafe() для планирования
|
||||
asyncio.Task из любого потока (sync или async).
|
||||
|
||||
Deferred init:
|
||||
- Bot instance создаётся позже в main.py. Метод set_bot() позволяет
|
||||
передать его после создания. До этого записи молча пропускаются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
|
||||
# Константы
|
||||
RECENT_HASHES_MAX_SIZE: Final[int] = 256
|
||||
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error
|
||||
|
||||
# Логгеры, от которых мы гарантированно не хотим получать уведомления,
|
||||
# даже если они вдруг выдадут ERROR (шум от транспортного уровня).
|
||||
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
|
||||
'aiohttp.access',
|
||||
'aiohttp.client',
|
||||
'aiohttp.internal',
|
||||
'uvicorn.access',
|
||||
'uvicorn.error',
|
||||
'uvicorn.protocols',
|
||||
'websockets',
|
||||
'asyncio',
|
||||
)
|
||||
|
||||
|
||||
class TelegramErrorHandler(logging.Handler):
|
||||
"""Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат.
|
||||
|
||||
Использует существующий механизм троттлинга и буферизации из
|
||||
``app.middlewares.global_error.send_error_to_admin_chat``.
|
||||
|
||||
Usage::
|
||||
|
||||
handler = TelegramErrorHandler()
|
||||
handler.setLevel(logging.ERROR)
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
# Позже, когда Bot создан:
|
||||
handler.set_bot(bot)
|
||||
"""
|
||||
|
||||
def __init__(self, level: int = logging.ERROR) -> None:
|
||||
super().__init__(level=level)
|
||||
self._bot: Bot | None = None
|
||||
# LRU-подобный кеш хешей недавних сообщений: hash -> timestamp
|
||||
self._recent_hashes: dict[str, float] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
"""Устанавливает Bot instance для отправки сообщений.
|
||||
|
||||
Вызывается из main.py после создания бота.
|
||||
"""
|
||||
self._bot = bot
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# logging.Handler interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Обрабатывает log record.
|
||||
|
||||
Синхронный метод (требование logging). Планирует async-отправку
|
||||
через event loop.
|
||||
"""
|
||||
# 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel)
|
||||
if record.levelno < logging.ERROR:
|
||||
return
|
||||
|
||||
# 2. Уже отправлено через GlobalErrorMiddleware / @error_handler
|
||||
if getattr(record, '_admin_notified', False):
|
||||
return
|
||||
|
||||
# 3. Фильтруем шумные логгеры
|
||||
if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
|
||||
return
|
||||
|
||||
# 4. Бот ещё не инициализирован -- пропускаем
|
||||
bot = self._bot
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
# 5. Дедупликация по хешу (logger_name + message)
|
||||
msg_hash = self._compute_hash(record)
|
||||
now = time.monotonic()
|
||||
|
||||
# Чистим просроченные записи (ленивая очистка)
|
||||
self._evict_stale(now)
|
||||
|
||||
if msg_hash in self._recent_hashes:
|
||||
return
|
||||
self._recent_hashes[msg_hash] = now
|
||||
|
||||
# 6. Планируем отправку через event loop
|
||||
self._schedule_send(bot, record)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _compute_hash(record: logging.LogRecord) -> str:
|
||||
"""Вычисляет короткий хеш для дедупликации.
|
||||
|
||||
Хешируем имя логгера + сообщение (без timestamp).
|
||||
"""
|
||||
raw = f'{record.name}:{record.getMessage()}'
|
||||
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
|
||||
|
||||
def _evict_stale(self, now: float) -> None:
|
||||
"""Удаляет устаревшие записи из кеша хешей."""
|
||||
if not self._recent_hashes:
|
||||
return
|
||||
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
|
||||
for k in stale_keys:
|
||||
self._recent_hashes.pop(k, None)
|
||||
# Принудительная очистка при переполнении — удаляем самые старые
|
||||
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
|
||||
sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get)
|
||||
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
|
||||
self._recent_hashes.pop(k, None)
|
||||
|
||||
def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None:
|
||||
"""Планирует асинхронную отправку в event loop.
|
||||
|
||||
Работает из любого потока:
|
||||
- Если вызов из async-контекста -- создаём Task напрямую.
|
||||
- Если из другого потока -- используем call_soon_threadsafe.
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# Нет running loop -- мы в стороннем потоке без loop.
|
||||
# Пытаемся получить loop, привязанный к основному потоку.
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
return
|
||||
loop.call_soon_threadsafe(self._create_send_task, bot, record, loop)
|
||||
except RuntimeError:
|
||||
return
|
||||
else:
|
||||
# Мы в async-контексте -- создаём task напрямую
|
||||
self._create_send_task(bot, record, loop)
|
||||
|
||||
def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Создаёт asyncio.Task для отправки уведомления."""
|
||||
loop.create_task(self._send(bot, record))
|
||||
|
||||
@staticmethod
|
||||
async def _send(bot: Bot, record: logging.LogRecord) -> None:
|
||||
"""Отправляет log record в админский чат через существующую инфраструктуру."""
|
||||
try:
|
||||
# Ленивый импорт -- избегаем циклических зависимостей при старте
|
||||
from app.middlewares.global_error import send_error_to_admin_chat
|
||||
|
||||
# Формируем pseudo-Exception из log record
|
||||
error = _make_log_record_error(record)
|
||||
|
||||
context_parts: list[str] = [f'Logger: {record.name}']
|
||||
if record.funcName:
|
||||
context_parts.append(f'Function: {record.funcName}')
|
||||
if record.pathname and record.lineno:
|
||||
context_parts.append(f'Location: {record.pathname}:{record.lineno}')
|
||||
|
||||
context = '\n'.join(context_parts)
|
||||
|
||||
# Извлекаем traceback из log record (если есть exc_info)
|
||||
tb_override: str | None = None
|
||||
if record.exc_info and record.exc_info[2] is not None:
|
||||
import traceback
|
||||
|
||||
tb_override = ''.join(traceback.format_exception(*record.exc_info))
|
||||
elif record.exc_text:
|
||||
tb_override = record.exc_text
|
||||
|
||||
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
|
||||
|
||||
except Exception:
|
||||
# Ни в коем случае не даём исключению утечь -- это logging handler,
|
||||
# рекурсия убьёт приложение.
|
||||
pass
|
||||
|
||||
|
||||
def _make_log_record_error(record: logging.LogRecord) -> Exception:
|
||||
"""Создаёт Exception-обёртку для LogRecord.
|
||||
|
||||
send_error_to_admin_chat использует type(error).__name__ как error_type.
|
||||
Мы динамически создаём класс с правильным именем, чтобы не мутировать
|
||||
общий класс между вызовами.
|
||||
"""
|
||||
class_name = f'Log{record.levelname.capitalize()}'
|
||||
error_cls = type(
|
||||
class_name,
|
||||
(Exception,),
|
||||
{
|
||||
'__str__': lambda self: self.args[0] if self.args else '',
|
||||
},
|
||||
)
|
||||
error = error_cls(record.getMessage())
|
||||
error.record = record # type: ignore[attr-defined]
|
||||
return error
|
||||
+17
-2
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
@@ -204,13 +205,27 @@ class AuthMiddleware(BaseMiddleware):
|
||||
data['is_admin'] = settings.is_admin(user.id)
|
||||
|
||||
result = await handler(event, data)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
# Соединение закрылось (таймаут после долгой операции) - просто логируем
|
||||
logger.warning(f'⚠️ Соединение с БД закрыто после обработки, пропускаем commit: {conn_err}')
|
||||
return result
|
||||
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
# Соединение с БД закрылось - не пытаемся rollback
|
||||
logger.error(f'Ошибка соединения с БД в AuthMiddleware: {conn_err}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в AuthMiddleware: {e}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
await db.rollback()
|
||||
try:
|
||||
await db.rollback()
|
||||
except (InterfaceError, OperationalError):
|
||||
pass # Соединение уже закрыто
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, PreCheckoutQuery, TelegramObject, User as TgUser
|
||||
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlacklistMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
user: TgUser | None = None
|
||||
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.id, user.username)
|
||||
|
||||
if not is_blacklisted:
|
||||
return await handler(event, data)
|
||||
|
||||
logger.warning(f'🚫 Пользователь {user.id} (@{user.username}) из черного списка: {reason}')
|
||||
|
||||
block_text = (
|
||||
f'🚫 Доступ запрещен\n\nПричина: {reason}\n\nЕсли вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_text)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(block_text, show_alert=True)
|
||||
elif isinstance(event, PreCheckoutQuery):
|
||||
await event.answer(ok=False, error_message='Доступ запрещен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки сообщения о блокировке пользователю {user.id}: {e}')
|
||||
|
||||
return None
|
||||
@@ -396,10 +396,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if not user or not user.subscription:
|
||||
return
|
||||
|
||||
# НЕ реактивируем подписку заблокированных пользователей
|
||||
# НЕ реактивируем подписку заблокированным пользователям
|
||||
if user.status == UserStatus.BLOCKED.value:
|
||||
logger.info(
|
||||
'🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
|
||||
'🚫 Пропуск реактивации для заблокированного пользователя %s',
|
||||
telegram_id,
|
||||
)
|
||||
return
|
||||
|
||||
+218
-41
@@ -1,14 +1,60 @@
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery, TelegramObject
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, TelegramObject
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.services.startup_notification_service import _get_error_recommendations
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Константы
|
||||
ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5
|
||||
ERROR_BUFFER_MAX_SIZE: Final[int] = 10
|
||||
ERROR_MESSAGE_MAX_LENGTH: Final[int] = 500
|
||||
REPORT_SEPARATOR_WIDTH: Final[int] = 50
|
||||
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
|
||||
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
|
||||
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
|
||||
|
||||
# Фразы ошибок Telegram API
|
||||
OLD_QUERY_PHRASES: Final[tuple[str, ...]] = (
|
||||
'query is too old',
|
||||
'query id is invalid',
|
||||
'response timeout expired',
|
||||
)
|
||||
BAD_REQUEST_PHRASES: Final[tuple[str, ...]] = (
|
||||
'message not found',
|
||||
'chat not found',
|
||||
'bot was blocked by the user',
|
||||
'user is deactivated',
|
||||
)
|
||||
TOPIC_ERROR_PHRASES: Final[tuple[str, ...]] = (
|
||||
'topic must be specified',
|
||||
'topic_closed',
|
||||
'topic_deleted',
|
||||
'forum_closed',
|
||||
)
|
||||
MESSAGE_NOT_MODIFIED_PHRASE: Final[str] = 'message is not modified'
|
||||
BOT_BLOCKED_PHRASE: Final[str] = 'bot was blocked'
|
||||
USER_DEACTIVATED_PHRASE: Final[str] = 'user is deactivated'
|
||||
CHAT_NOT_FOUND_PHRASE: Final[str] = 'chat not found'
|
||||
MESSAGE_NOT_FOUND_PHRASE: Final[str] = 'message not found'
|
||||
|
||||
# Троттлинг для предотвращения спама ошибками
|
||||
_last_error_notification: datetime | None = None
|
||||
_error_notification_cooldown = timedelta(minutes=ERROR_NOTIFICATION_COOLDOWN_MINUTES)
|
||||
_error_buffer: list[tuple[str, str, str]] = [] # (error_type, error_message, traceback)
|
||||
|
||||
|
||||
class GlobalErrorMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
@@ -20,81 +66,83 @@ class GlobalErrorMiddleware(BaseMiddleware):
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
return await self._handle_telegram_error(event, e)
|
||||
return await self._handle_telegram_error(event, e, data)
|
||||
except (InterfaceError, OperationalError) as e:
|
||||
# Ошибки соединения с БД (таймаут после долгих операций) - логируем, но не спамим админам
|
||||
logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware: %s', e)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Неожиданная ошибка в GlobalErrorMiddleware: {e}', exc_info=True)
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=%s): %s', user_info, e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if self._is_old_query_error(error_message):
|
||||
return await self._handle_old_query(event, error)
|
||||
if self._is_message_not_modified_error(error_message):
|
||||
return await self._handle_message_not_modified(event, error)
|
||||
return await self._handle_message_not_modified(event, error, data)
|
||||
if self._is_topic_required_error(error_message):
|
||||
# Канал с топиками — просто игнорируем
|
||||
logger.debug(f'📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}')
|
||||
logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика: %s', error)
|
||||
return None
|
||||
if self._is_bad_request_error(error_message):
|
||||
return await self._handle_bad_request(event, error)
|
||||
logger.error(f'Неизвестная Telegram API ошибка: {error}')
|
||||
return await self._handle_bad_request(event, error, data)
|
||||
|
||||
# Неизвестная ошибка — логируем
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('Неизвестная Telegram API ошибка (user=%s): %s', user_info, error)
|
||||
raise error
|
||||
|
||||
def _is_old_query_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['query is too old', 'query id is invalid', 'response timeout expired']
|
||||
)
|
||||
return any(phrase in error_message for phrase in OLD_QUERY_PHRASES)
|
||||
|
||||
def _is_message_not_modified_error(self, error_message: str) -> bool:
|
||||
return 'message is not modified' in error_message
|
||||
return MESSAGE_NOT_MODIFIED_PHRASE in error_message
|
||||
|
||||
def _is_bad_request_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated']
|
||||
)
|
||||
return any(phrase in error_message for phrase in BAD_REQUEST_PHRASES)
|
||||
|
||||
def _is_topic_required_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed']
|
||||
)
|
||||
return any(phrase in error_message for phrase in TOPIC_ERROR_PHRASES)
|
||||
|
||||
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
if isinstance(event, CallbackQuery):
|
||||
user_info = self._get_user_info(event)
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
|
||||
logger.warning("[GlobalErrorMiddleware] Игнорируем устаревший callback '%s' от %s", event.data, user_info)
|
||||
else:
|
||||
logger.warning(f'🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}')
|
||||
logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос: %s', error)
|
||||
|
||||
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
logger.debug(f'📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}')
|
||||
async def _handle_message_not_modified(
|
||||
self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]
|
||||
):
|
||||
logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено: %s', error)
|
||||
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
logger.debug("✅ Успешно ответили на callback после 'message not modified'")
|
||||
logger.debug("Успешно ответили на callback после 'message not modified'")
|
||||
except TelegramBadRequest as answer_error:
|
||||
if not self._is_old_query_error(str(answer_error).lower()):
|
||||
logger.error(f'❌ Ошибка при ответе на callback: {answer_error}')
|
||||
logger.warning('Ошибка при ответе на callback: %s', answer_error)
|
||||
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if 'bot was blocked' in error_message:
|
||||
if BOT_BLOCKED_PHRASE in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
|
||||
logger.info(f'🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}')
|
||||
logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info)
|
||||
return
|
||||
if 'user is deactivated' in error_message:
|
||||
if USER_DEACTIVATED_PHRASE in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
|
||||
logger.info(f'👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}')
|
||||
logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info)
|
||||
return
|
||||
if 'chat not found' in error_message or 'message not found' in error_message:
|
||||
logger.warning(f'🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}')
|
||||
if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message:
|
||||
logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error)
|
||||
return
|
||||
logger.error(f'❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}')
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=%s): %s', user_info, error)
|
||||
raise error
|
||||
|
||||
def _get_user_info(self, event: TelegramObject) -> str:
|
||||
@@ -130,13 +178,13 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
def _count_error(self, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if 'query is too old' in error_message:
|
||||
if OLD_QUERY_PHRASES[0] in error_message:
|
||||
self.error_counts['old_queries'] += 1
|
||||
elif 'message is not modified' in error_message:
|
||||
elif MESSAGE_NOT_MODIFIED_PHRASE in error_message:
|
||||
self.error_counts['message_not_modified'] += 1
|
||||
elif 'bot was blocked' in error_message:
|
||||
elif BOT_BLOCKED_PHRASE in error_message:
|
||||
self.error_counts['bot_blocked'] += 1
|
||||
elif 'user is deactivated' in error_message:
|
||||
elif USER_DEACTIVATED_PHRASE in error_message:
|
||||
self.error_counts['user_deactivated'] += 1
|
||||
else:
|
||||
self.error_counts['other_errors'] += 1
|
||||
@@ -147,3 +195,132 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
def reset_statistics(self):
|
||||
for key in self.error_counts:
|
||||
self.error_counts[key] = 0
|
||||
|
||||
|
||||
async def send_error_to_admin_chat(
|
||||
bot: Bot, error: Exception, context: str = '', tb_override: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление об ошибке в админский чат с троттлингом.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
error: Исключение
|
||||
context: Дополнительный контекст (например, информация о пользователе)
|
||||
tb_override: Готовый traceback (если вызывается не из except-блока)
|
||||
|
||||
Returns:
|
||||
bool: True если уведомление отправлено
|
||||
"""
|
||||
global _last_error_notification
|
||||
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
return False
|
||||
|
||||
error_type = type(error).__name__
|
||||
error_message = str(error)[:ERROR_MESSAGE_MAX_LENGTH]
|
||||
tb_str = tb_override or traceback.format_exc()
|
||||
if tb_str == 'NoneType: None\n' or tb_str == 'NoneType: None':
|
||||
tb_str = '(no traceback available)'
|
||||
|
||||
# Добавляем в буфер
|
||||
_error_buffer.append((error_type, error_message, tb_str))
|
||||
if len(_error_buffer) > ERROR_BUFFER_MAX_SIZE:
|
||||
_error_buffer.pop(0)
|
||||
|
||||
# Проверяем троттлинг
|
||||
now = datetime.utcnow()
|
||||
if _last_error_notification and (now - _last_error_notification) < _error_notification_cooldown:
|
||||
logger.debug('Ошибка добавлена в буфер, троттлинг активен: %s', error_type)
|
||||
return False
|
||||
|
||||
_last_error_notification = now
|
||||
|
||||
try:
|
||||
timestamp = format_local_datetime(now, DATETIME_FORMAT)
|
||||
separator = '=' * REPORT_SEPARATOR_WIDTH
|
||||
|
||||
# Формируем лог-файл со всеми ошибками из буфера
|
||||
log_lines = [
|
||||
'ERROR REPORT',
|
||||
separator,
|
||||
f'Timestamp: {timestamp}',
|
||||
f'Errors in buffer: {len(_error_buffer)}',
|
||||
'',
|
||||
]
|
||||
|
||||
for i, (err_type, err_msg, err_tb) in enumerate(_error_buffer):
|
||||
log_lines.extend(
|
||||
[
|
||||
separator,
|
||||
f'ERROR #{i}: {err_type}',
|
||||
separator,
|
||||
f'Message: {err_msg}',
|
||||
'',
|
||||
'Traceback:',
|
||||
err_tb,
|
||||
'',
|
||||
]
|
||||
)
|
||||
|
||||
log_content = '\n'.join(log_lines)
|
||||
|
||||
# Очищаем буфер после отправки
|
||||
errors_count = len(_error_buffer)
|
||||
_error_buffer.clear()
|
||||
|
||||
file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt'
|
||||
file = BufferedInputFile(
|
||||
file=log_content.encode('utf-8'),
|
||||
filename=file_name,
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f'<b>Remnawave Bedolaga Bot</b>\n\n'
|
||||
f'⚠️ Ошибка во время работы\n\n'
|
||||
f'<b>Тип:</b> <code>{error_type}</code>\n'
|
||||
f'<b>Ошибок в отчёте:</b> {errors_count}\n'
|
||||
)
|
||||
if context:
|
||||
message_text += f'<b>Контекст:</b> {context}\n'
|
||||
|
||||
# Добавляем рекомендации если есть
|
||||
recommendations = _get_error_recommendations(error_message)
|
||||
if recommendations:
|
||||
message_text += f'\n{recommendations}\n'
|
||||
|
||||
message_text += f'\n<i>{timestamp}</i>'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='💬 Сообщить разработчику',
|
||||
url=DEVELOPER_CONTACT_URL,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
message_kwargs: dict = {
|
||||
'chat_id': chat_id,
|
||||
'document': file,
|
||||
'caption': message_text,
|
||||
'parse_mode': ParseMode.HTML,
|
||||
'reply_markup': keyboard,
|
||||
}
|
||||
|
||||
if topic_id:
|
||||
message_kwargs['message_thread_id'] = topic_id
|
||||
|
||||
await bot.send_document(**message_kwargs)
|
||||
logger.info('Уведомление об ошибке отправлено в чат %s', chat_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об ошибке: %s', e, extra={'_admin_notified': True})
|
||||
return False
|
||||
|
||||
@@ -288,9 +288,7 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
user_status = '🆕 Новый' if not user.has_had_paid_subscription else '🔄 Существующий'
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
@@ -308,33 +306,79 @@ class AdminNotificationService:
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
# Получаем название тарифа (если режим тарифов)
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
message_lines = [
|
||||
'🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
f'👥 <b>Статус:</b> {user_status}',
|
||||
'',
|
||||
]
|
||||
|
||||
{promo_block}
|
||||
# Промогруппа — только название, без скидок
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
|
||||
else:
|
||||
message_lines.append('🏷️ <b>Промогруппа:</b> —')
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
{payment_block}
|
||||
# Тариф триала (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'📦 <b>Тариф:</b> {tariff_name}')
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
message_lines.append('')
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
message_lines.extend(
|
||||
[
|
||||
'⏰ <b>Параметры триала:</b>',
|
||||
f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней',
|
||||
f'📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}',
|
||||
f'📱 Устройства: {trial_device_limit}',
|
||||
f'🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else "По умолчанию"}',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message(message)
|
||||
if payment_block:
|
||||
message_lines.append(payment_block)
|
||||
|
||||
message_lines.append('')
|
||||
message_lines.append(
|
||||
f'📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}'
|
||||
)
|
||||
|
||||
# Реферер — только если есть
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 <b>Реферер:</b> {referrer_info}')
|
||||
|
||||
message_lines.append('')
|
||||
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о триале: {e}')
|
||||
return False
|
||||
|
||||
async def _get_tariff_name(self, db: AsyncSession, subscription: Subscription) -> str | None:
|
||||
"""Получает название тарифа подписки, если он есть."""
|
||||
if not subscription.tariff_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
return tariff.name
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -344,6 +388,7 @@ class AdminNotificationService:
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: int | None = None,
|
||||
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
|
||||
) -> bool:
|
||||
try:
|
||||
total_amount = (
|
||||
@@ -371,93 +416,138 @@ class AdminNotificationService:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
event_type = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА' if was_trial_conversion else '💎 ПОКУПКА ПОДПИСКИ'
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = '🎯 Конверсия из триала'
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = '🔄 Продление/Обновление'
|
||||
# Определяем тип операции и заголовок
|
||||
if purchase_type == 'tariff_switch':
|
||||
event_title = '🔄 СМЕНА ТАРИФА'
|
||||
user_status = 'Смена тарифа'
|
||||
elif was_trial_conversion:
|
||||
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
|
||||
user_status = 'Конверсия'
|
||||
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
|
||||
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
|
||||
user_status = 'Продление'
|
||||
else:
|
||||
user_status = '🆕 Первая покупка'
|
||||
event_title = '💎 ПОКУПКА ПОДПИСКИ'
|
||||
user_status = 'Первая покупка'
|
||||
|
||||
# Получаем название тарифа
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else 'Баланс'
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
transaction_id = transaction.id if transaction else '—'
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
# Формируем компактное сообщение
|
||||
message_lines = [
|
||||
f'<b>{event_title}</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
{promo_block}
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(total_amount)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction_id}
|
||||
# Тариф (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'💵 <b>{settings.format_price(total_amount)}</b> • {payment_method}',
|
||||
f'📅 {period_days} дн. • до {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
|
||||
f'📊 {self._format_traffic(subscription.traffic_limit_gb)} • 📱 {subscription.device_limit} устр.',
|
||||
f'🌐 {servers_info}',
|
||||
]
|
||||
)
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
# Баланс после покупки
|
||||
message_lines.append(f'💰 Баланс: {settings.format_price(user.balance_kopeks)}')
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
# Реферер (только если есть)
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реф: {referrer_info}')
|
||||
|
||||
return await self._send_message(message)
|
||||
# ID транзакции (только если есть)
|
||||
if transaction:
|
||||
message_lines.append(f'🆔 #{transaction.id}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о покупке: {e}')
|
||||
return False
|
||||
|
||||
async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool:
|
||||
"""Отправляет уведомление о новых обновлениях"""
|
||||
"""Отправляет уведомление о новых обновлениях."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.utils.markdown_to_telegram import github_markdown_to_telegram_html, truncate_for_blockquote
|
||||
|
||||
repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
|
||||
release_url = f'https://github.com/{repo}/releases/tag/{latest_version.tag_name}'
|
||||
repo_url = f'https://github.com/{repo}'
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
|
||||
if latest_version.prerelease:
|
||||
update_type = '🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ'
|
||||
type_icon = '🧪'
|
||||
header = '🧪 <b>Pre-release</b>'
|
||||
elif latest_version.is_dev:
|
||||
update_type = '🔧 DEV ВЕРСИЯ'
|
||||
type_icon = '🔧'
|
||||
header = '🔧 <b>Dev build</b>'
|
||||
else:
|
||||
update_type = '📦 НОВАЯ ВЕРСИЯ'
|
||||
type_icon = '📦'
|
||||
header = '🆕 <b>Доступно обновление</b>'
|
||||
|
||||
description = latest_version.short_description
|
||||
if len(description) > 200:
|
||||
description = description[:197] + '...'
|
||||
# -- message prefix (everything before blockquote) --
|
||||
prefix_lines = [
|
||||
header,
|
||||
'',
|
||||
f'<code>{current_version}</code> → <b><a href="{release_url}">{latest_version.tag_name}</a></b>',
|
||||
f'📅 {latest_version.formatted_date}',
|
||||
'',
|
||||
]
|
||||
message_prefix = '\n'.join(prefix_lines)
|
||||
|
||||
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
|
||||
# -- message suffix (everything after blockquote) --
|
||||
suffix_lines = ['']
|
||||
if total_updates > 1:
|
||||
suffix_lines.append(f'Доступно обновлений: <b>{total_updates}</b>')
|
||||
suffix_lines.extend(
|
||||
[
|
||||
f'<a href="{repo_url}">Репозиторий</a>',
|
||||
'',
|
||||
f'<i>{timestamp}</i>',
|
||||
]
|
||||
)
|
||||
message_suffix = '\n'.join(suffix_lines)
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
|
||||
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
|
||||
# -- description in blockquote --
|
||||
raw_description = getattr(latest_version, 'full_description', '') or latest_version.short_description
|
||||
description_html = github_markdown_to_telegram_html(raw_description)
|
||||
|
||||
📝 <b>Описание:</b>
|
||||
{description}
|
||||
|
||||
🔢 <b>Всего доступно обновлений:</b> {total_updates}
|
||||
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
if description_html:
|
||||
description_html = truncate_for_blockquote(
|
||||
description_html,
|
||||
message_prefix=message_prefix,
|
||||
message_suffix=message_suffix,
|
||||
)
|
||||
message = f'{message_prefix}<blockquote expandable>{description_html}</blockquote>{message_suffix}'
|
||||
else:
|
||||
message = f'{message_prefix}{message_suffix}'
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -500,35 +590,81 @@ class AdminNotificationService:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
# --- Основной блок ---
|
||||
message_lines: list[str] = [
|
||||
'💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
{promo_block}
|
||||
message_lines.append(f'💳 {topup_status}')
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
# Промогруппа -- только название
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
|
||||
💰 <b>Баланс:</b>
|
||||
📉 Было: {settings.format_price(old_balance)}
|
||||
📈 Стало: {settings.format_price(user.balance_kopeks)}
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
message_lines.append('')
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
# --- Детали пополнения ---
|
||||
message_lines.extend(
|
||||
[
|
||||
f'💵 <b>{settings.format_price(transaction.amount_kopeks)}</b> | {payment_method}',
|
||||
'',
|
||||
f'📉 {settings.format_price(old_balance)} → 📈 {settings.format_price(user.balance_kopeks)}'
|
||||
f' (<b>+{settings.format_price(balance_change)}</b>)',
|
||||
]
|
||||
)
|
||||
|
||||
⏰ <i>{timestamp}</i>"""
|
||||
# --- Подписка ---
|
||||
message_lines.append(f'📱 Подписка: {subscription_status}')
|
||||
|
||||
# --- Реферер (только если есть) ---
|
||||
if referrer_info and referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реферер: {referrer_info}')
|
||||
|
||||
# --- Expandable blockquote с техническими деталями ---
|
||||
detail_lines: list[str] = [
|
||||
f'ID транзакции: {transaction.id}',
|
||||
f'Способ оплаты: {transaction.payment_method or "balance"}',
|
||||
]
|
||||
|
||||
if transaction.external_id:
|
||||
detail_lines.append(f'Внешний ID: {transaction.external_id}')
|
||||
|
||||
if transaction.description:
|
||||
desc = transaction.description
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + '...'
|
||||
detail_lines.append(f'Описание: {desc}')
|
||||
|
||||
if transaction.created_at:
|
||||
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
|
||||
|
||||
if transaction.completed_at:
|
||||
detail_lines.append(f'Завершена: {format_local_datetime(transaction.completed_at, "%d.%m.%Y %H:%M:%S")}')
|
||||
|
||||
if transaction.receipt_uuid:
|
||||
detail_lines.append(f'Чек UUID: {transaction.receipt_uuid}')
|
||||
|
||||
blockquote_body = '\n'.join(detail_lines)
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'<blockquote expandable>{blockquote_body}</blockquote>',
|
||||
]
|
||||
)
|
||||
|
||||
message_lines.append(f'<i>{timestamp}</i>')
|
||||
|
||||
return '\n'.join(message_lines)
|
||||
|
||||
async def _reload_topup_notification_entities(
|
||||
self,
|
||||
@@ -1081,21 +1217,27 @@ class AdminNotificationService:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
method_names = {
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
method_names: dict[str, str] = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': f'💳 {mulenpay_name} (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'mulenpay': f'💳 {settings.get_mulenpay_display_name()} (карта)',
|
||||
'pal24': f'🏦 {settings.get_pal24_display_name()} (СБП)',
|
||||
'cryptobot': f'🪙 {settings.get_cryptobot_display_name()} (крипто)',
|
||||
'heleket': f'🪙 {settings.get_heleket_display_name()} (крипто)',
|
||||
'wata': f'💳 {settings.get_wata_display_name()}',
|
||||
'platega': f'💳 {settings.get_platega_display_name()}',
|
||||
'cloudpayments': f'💳 {settings.get_cloudpayments_display_name()}',
|
||||
'freekassa': f'💳 {settings.get_freekassa_display_name()}',
|
||||
'kassa_ai': f'💳 {settings.get_kassa_ai_display_name()}',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса',
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, '💰 С баланса')
|
||||
return method_names.get(payment_method, f'💳 {payment_method}')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
@@ -1369,65 +1511,81 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
'traffic': ('📊 ИЗМЕНЕНИЕ ТРАФИКА', 'трафик'),
|
||||
'devices': ('📱 ИЗМЕНЕНИЕ УСТРОЙСТВ', 'количество устройств'),
|
||||
'servers': ('🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ', 'серверы'),
|
||||
'modem': ('📡 ИЗМЕНЕНИЕ МОДЕМА', 'модем'),
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ('⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ', 'параметры'))
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
# Определяем заголовок по типу операции
|
||||
update_titles = {
|
||||
'traffic': '📊 ДОКУПКА ТРАФИКА',
|
||||
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
|
||||
'servers': '🌐 СМЕНА СЕРВЕРОВ',
|
||||
'modem': '📡 МОДЕМ',
|
||||
}
|
||||
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
|
||||
|
||||
# Получаем название тарифа
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
# Формируем компактное сообщение
|
||||
message_lines = [
|
||||
f'{title}',
|
||||
f'<b>{title}</b>',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
'',
|
||||
promo_block,
|
||||
'',
|
||||
'🔧 <b>Изменение:</b>',
|
||||
f'📋 Параметр: {param_name}',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
# Тариф (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
# Форматируем изменение в зависимости от типа
|
||||
if update_type == 'servers':
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
message_lines.extend(
|
||||
[
|
||||
f'📉 Было: {old_servers_info}',
|
||||
f'📈 Стало: {new_servers_info}',
|
||||
]
|
||||
)
|
||||
message_lines.append(f'🔄 {old_servers_info} → {new_servers_info}')
|
||||
elif update_type == 'traffic':
|
||||
old_formatted = self._format_update_value(old_value, update_type)
|
||||
new_formatted = self._format_update_value(new_value, update_type)
|
||||
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
|
||||
elif update_type == 'devices':
|
||||
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
|
||||
elif update_type == 'modem':
|
||||
old_state = '✅ Вкл' if old_value else '❌ Выкл'
|
||||
new_state = '✅ Вкл' if new_value else '❌ Выкл'
|
||||
message_lines.append(f'🔄 {old_state} → {new_state}')
|
||||
else:
|
||||
message_lines.extend(
|
||||
[
|
||||
f'📉 Было: {self._format_update_value(old_value, update_type)}',
|
||||
f'📈 Стало: {self._format_update_value(new_value, update_type)}',
|
||||
]
|
||||
)
|
||||
message_lines.append(f'🔄 {old_value} → {new_value}')
|
||||
|
||||
# Стоимость операции
|
||||
if price_paid > 0:
|
||||
message_lines.append(f'💰 Доплачено: {settings.format_price(price_paid)}')
|
||||
message_lines.append(f'💵 <b>{settings.format_price(price_paid)}</b>')
|
||||
else:
|
||||
message_lines.append('💸 Бесплатно')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}',
|
||||
f'💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}',
|
||||
f'🔗 <b>Рефер:</b> {referrer_info}',
|
||||
f'📅 До {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
|
||||
f'💰 Баланс: {settings.format_price(user.balance_kopeks)}',
|
||||
]
|
||||
)
|
||||
|
||||
# Реферер (только если есть)
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реф: {referrer_info}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
@@ -27,6 +28,9 @@ class BlacklistService:
|
||||
interval_hours = self.get_blacklist_update_interval_hours()
|
||||
self.update_interval = timedelta(hours=interval_hours)
|
||||
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
|
||||
# Кэш результатов проверки: {telegram_id: (is_blacklisted, reason, timestamp)}
|
||||
self._check_cache: dict[int, tuple[bool, str | None, float]] = {}
|
||||
self._cache_ttl = 300 # 5 минут
|
||||
|
||||
def is_blacklist_check_enabled(self) -> bool:
|
||||
"""Проверяет, включена ли проверка черного списка"""
|
||||
@@ -117,6 +121,7 @@ class BlacklistService:
|
||||
|
||||
self.blacklist_data = blacklist_data
|
||||
self.last_update = datetime.utcnow()
|
||||
self._check_cache.clear()
|
||||
logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей')
|
||||
return True
|
||||
|
||||
@@ -141,9 +146,17 @@ class BlacklistService:
|
||||
if not self.is_blacklist_check_enabled():
|
||||
return False, None
|
||||
|
||||
# Проверяем кэш
|
||||
now = time.monotonic()
|
||||
cached = self._check_cache.get(telegram_id)
|
||||
if cached is not None:
|
||||
is_bl, reason, ts = cached
|
||||
if now - ts < self._cache_ttl:
|
||||
return is_bl, reason
|
||||
|
||||
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
|
||||
if self.should_ignore_admins() and self.is_admin(telegram_id):
|
||||
logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка')
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
# Если черный список пуст или устарел, обновляем его
|
||||
@@ -156,17 +169,21 @@ class BlacklistService:
|
||||
for bl_id, bl_username, bl_reason in self.blacklist_data:
|
||||
if bl_id == telegram_id:
|
||||
logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}')
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
# Проверяем по username, если он передан
|
||||
if username:
|
||||
username_lower = username.lower().lstrip('@')
|
||||
for bl_id, bl_username, bl_reason in self.blacklist_data:
|
||||
if bl_username and (bl_username == username or bl_username == f'@{username}'):
|
||||
if bl_username and bl_username.lower().lstrip('@') == username_lower:
|
||||
logger.info(
|
||||
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
|
||||
)
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]:
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Сервис для проверки пользователей, заблокировавших бота.
|
||||
|
||||
Проверяет возможность отправки сообщений пользователям и позволяет
|
||||
очистить БД и панель Remnawave от неактивных пользователей.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaignRegistration,
|
||||
ButtonClickLog,
|
||||
CabinetRefreshToken,
|
||||
CloudPaymentsPayment,
|
||||
ContestAttempt,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PollResponse,
|
||||
PromoCodeUse,
|
||||
ReferralContestEvent,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
TicketNotification,
|
||||
Transaction,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
WataPayment,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YooKassaPayment,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlockCheckStatus(Enum):
|
||||
"""Статус проверки блокировки пользователя."""
|
||||
|
||||
BLOCKED = 'blocked'
|
||||
ACTIVE = 'active'
|
||||
NO_TELEGRAM_ID = 'no_telegram_id'
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
class BlockedUserAction(Enum):
|
||||
"""Действия над заблокированными пользователями."""
|
||||
|
||||
DELETE_FROM_DB = 'delete_from_db'
|
||||
DELETE_FROM_REMNAWAVE = 'delete_from_remnawave'
|
||||
DELETE_BOTH = 'delete_both'
|
||||
MARK_AS_BLOCKED = 'mark_as_blocked'
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockCheckResult:
|
||||
"""Результат проверки одного пользователя."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
full_name: str
|
||||
status: BlockCheckStatus
|
||||
error_message: str | None = None
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockedUsersScanResult:
|
||||
"""Результат сканирования пользователей на блокировку."""
|
||||
|
||||
total_checked: int = 0
|
||||
blocked_users: list[BlockCheckResult] = field(default_factory=list)
|
||||
active_users: int = 0
|
||||
errors: int = 0
|
||||
skipped_no_telegram: int = 0
|
||||
scan_duration_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return len(self.blocked_users)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupResult:
|
||||
"""Результат очистки заблокированных пользователей."""
|
||||
|
||||
deleted_from_db: int = 0
|
||||
deleted_from_remnawave: int = 0
|
||||
marked_as_blocked: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class BlockedUsersService:
|
||||
"""Сервис проверки и очистки заблокированных пользователей."""
|
||||
|
||||
# Задержка между проверками для избежания rate limit
|
||||
CHECK_DELAY_SECONDS: float = 0.05
|
||||
# Максимальное количество параллельных проверок
|
||||
MAX_CONCURRENT_CHECKS: int = 10
|
||||
# Задержка между API запросами к Remnawave (rate limit protection)
|
||||
API_DELAY_SECONDS: float = 0.15
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.remnawave_service = RemnaWaveService()
|
||||
|
||||
async def check_user_blocked(self, telegram_id: int) -> BlockCheckStatus:
|
||||
"""
|
||||
Проверяет, заблокировал ли пользователь бота.
|
||||
|
||||
Отправляет ChatAction.TYPING - это не создает видимого сообщения,
|
||||
но позволяет определить блокировку.
|
||||
"""
|
||||
try:
|
||||
await self.bot.send_chat_action(chat_id=telegram_id, action='typing')
|
||||
return BlockCheckStatus.ACTIVE
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь заблокировал бота
|
||||
return BlockCheckStatus.BLOCKED
|
||||
except TelegramBadRequest as e:
|
||||
error_lower = str(e).lower()
|
||||
if 'chat not found' in error_lower or 'user not found' in error_lower:
|
||||
# Пользователь удалил аккаунт или никогда не начинал диалог
|
||||
return BlockCheckStatus.BLOCKED
|
||||
logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
except TelegramAPIError as e:
|
||||
logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
except Exception as e:
|
||||
logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
|
||||
async def _check_single_user(self, user: User) -> BlockCheckResult:
|
||||
"""Проверяет одного пользователя."""
|
||||
if not user.telegram_id:
|
||||
return BlockCheckResult(
|
||||
user_id=user.id,
|
||||
telegram_id=None,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
status=BlockCheckStatus.NO_TELEGRAM_ID,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
|
||||
status = await self.check_user_blocked(user.telegram_id)
|
||||
|
||||
return BlockCheckResult(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
status=status,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
|
||||
async def scan_all_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
only_active: bool = True,
|
||||
batch_size: int = 100,
|
||||
progress_callback: Callable | None = None,
|
||||
) -> BlockedUsersScanResult:
|
||||
"""
|
||||
Сканирует всех пользователей на предмет блокировки бота.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
only_active: Проверять только активных пользователей
|
||||
batch_size: Размер батча для загрузки из БД
|
||||
progress_callback: Callback для отчета о прогрессе (checked, total)
|
||||
|
||||
Returns:
|
||||
Результат сканирования
|
||||
"""
|
||||
start_time = datetime.now(tz=UTC)
|
||||
result = BlockedUsersScanResult()
|
||||
|
||||
# Формируем запрос
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
if only_active:
|
||||
query = query.where(User.status == UserStatus.ACTIVE.value)
|
||||
query = query.where(User.telegram_id.isnot(None))
|
||||
|
||||
# Получаем всех пользователей
|
||||
users_result = await db.execute(query)
|
||||
all_users = users_result.scalars().all()
|
||||
total_users = len(all_users)
|
||||
|
||||
logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота')
|
||||
|
||||
# Проверяем пользователей батчами с ограничением параллелизма
|
||||
semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS)
|
||||
|
||||
async def check_with_semaphore(user: User) -> BlockCheckResult:
|
||||
async with semaphore:
|
||||
check_result = await self._check_single_user(user)
|
||||
await asyncio.sleep(self.CHECK_DELAY_SECONDS)
|
||||
return check_result
|
||||
|
||||
checked = 0
|
||||
for i in range(0, total_users, batch_size):
|
||||
batch = all_users[i : i + batch_size]
|
||||
tasks = [check_with_semaphore(user) for user in batch]
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for check_result in batch_results:
|
||||
if isinstance(check_result, Exception):
|
||||
result.errors += 1
|
||||
logger.error(f'Ошибка при проверке пользователя: {check_result}')
|
||||
continue
|
||||
|
||||
result.total_checked += 1
|
||||
|
||||
if check_result.status == BlockCheckStatus.BLOCKED:
|
||||
result.blocked_users.append(check_result)
|
||||
elif check_result.status == BlockCheckStatus.ACTIVE:
|
||||
result.active_users += 1
|
||||
elif check_result.status == BlockCheckStatus.NO_TELEGRAM_ID:
|
||||
result.skipped_no_telegram += 1
|
||||
else:
|
||||
result.errors += 1
|
||||
|
||||
checked += len(batch)
|
||||
if progress_callback:
|
||||
await progress_callback(checked, total_users)
|
||||
|
||||
result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds()
|
||||
|
||||
logger.info(
|
||||
f'Сканирование завершено: {result.blocked_count} заблокированных '
|
||||
f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с'
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def delete_user_from_remnawave(self, remnawave_uuid: str) -> bool:
|
||||
"""Удаляет пользователя из панели Remnawave."""
|
||||
if not remnawave_uuid:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not self.remnawave_service.is_configured:
|
||||
logger.warning('Remnawave API не настроен')
|
||||
return False
|
||||
|
||||
async with self.remnawave_service.get_api_client() as api:
|
||||
await api.delete_user(remnawave_uuid)
|
||||
logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave')
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'not found' in error_msg or '404' in error_msg:
|
||||
logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave')
|
||||
return True
|
||||
logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}')
|
||||
return False
|
||||
|
||||
async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool:
|
||||
"""
|
||||
Полностью удаляет пользователя из БД со всеми связанными данными.
|
||||
"""
|
||||
try:
|
||||
# Получаем пользователя
|
||||
user_result = await db.execute(
|
||||
select(User).options(selectinload(User.subscription)).where(User.id == user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
logger.warning(f'Пользователь {user_id} не найден в БД')
|
||||
return False
|
||||
|
||||
user_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
|
||||
# Удаляем связанные записи (порядок важен из-за foreign keys)
|
||||
|
||||
# 1. Платежные системы (до транзакций, т.к. ссылаются на них)
|
||||
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user.id))
|
||||
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user.id))
|
||||
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user.id))
|
||||
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user.id))
|
||||
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user.id))
|
||||
await db.execute(delete(WataPayment).where(WataPayment.user_id == user.id))
|
||||
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user.id))
|
||||
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user.id))
|
||||
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user.id))
|
||||
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user.id))
|
||||
|
||||
# 2. Транзакции (после платежей)
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
# 3. Подписки
|
||||
if user.subscription:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
|
||||
)
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user.id))
|
||||
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user.id))
|
||||
await db.execute(delete(SubscriptionEvent).where(SubscriptionEvent.user_id == user.id))
|
||||
|
||||
# 4. Тикеты (сначала зависимые)
|
||||
await db.execute(delete(TicketNotification).where(TicketNotification.user_id == user.id))
|
||||
await db.execute(delete(TicketMessage).where(TicketMessage.user_id == user.id))
|
||||
await db.execute(delete(Ticket).where(Ticket.user_id == user.id))
|
||||
|
||||
# 5. Остальные связи
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id))
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
await db.execute(delete(DiscountOffer).where(DiscountOffer.user_id == user.id))
|
||||
await db.execute(delete(SentNotification).where(SentNotification.user_id == user.id))
|
||||
await db.execute(delete(PollResponse).where(PollResponse.user_id == user.id))
|
||||
await db.execute(delete(ContestAttempt).where(ContestAttempt.user_id == user.id))
|
||||
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referrer_id == user.id))
|
||||
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referral_id == user.id))
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaignRegistration).where(AdvertisingCampaignRegistration.user_id == user.id)
|
||||
)
|
||||
await db.execute(delete(UserPromoGroup).where(UserPromoGroup.user_id == user.id))
|
||||
await db.execute(delete(CabinetRefreshToken).where(CabinetRefreshToken.user_id == user.id))
|
||||
await db.execute(delete(ButtonClickLog).where(ButtonClickLog.user_id == user.id))
|
||||
await db.execute(delete(WheelSpin).where(WheelSpin.user_id == user.id))
|
||||
|
||||
# Обнуляем referred_by_id у рефералов этого пользователя
|
||||
referrals_query = select(User).where(User.referred_by_id == user.id)
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
for referral in referrals_result.scalars().all():
|
||||
referral.referred_by_id = None
|
||||
|
||||
# Удаляем пользователя
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Пользователь {user_display} полностью удален из БД')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def mark_user_as_blocked(self, db: AsyncSession, user_id: int) -> bool:
|
||||
"""Помечает пользователя как заблокированного в БД."""
|
||||
try:
|
||||
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
user.updated_at = datetime.now(tz=UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка пометки пользователя {user_id}: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def cleanup_blocked_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
blocked_users: list[BlockCheckResult],
|
||||
action: BlockedUserAction,
|
||||
*,
|
||||
progress_callback: Callable | None = None,
|
||||
) -> CleanupResult:
|
||||
"""
|
||||
Выполняет очистку заблокированных пользователей.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
blocked_users: Список заблокированных пользователей
|
||||
action: Действие для выполнения
|
||||
progress_callback: Callback для отчета о прогрессе
|
||||
|
||||
Returns:
|
||||
Результат очистки
|
||||
"""
|
||||
result = CleanupResult()
|
||||
total = len(blocked_users)
|
||||
|
||||
for i, user_result in enumerate(blocked_users):
|
||||
try:
|
||||
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
|
||||
if user_result.remnawave_uuid:
|
||||
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
|
||||
if success:
|
||||
result.deleted_from_remnawave += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
|
||||
# Задержка для избежания rate limit
|
||||
await asyncio.sleep(self.API_DELAY_SECONDS)
|
||||
|
||||
if action in (BlockedUserAction.DELETE_FROM_DB, BlockedUserAction.DELETE_BOTH):
|
||||
success = await self.delete_user_from_db(db, user_result.user_id)
|
||||
if success:
|
||||
result.deleted_from_db += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из БД')
|
||||
|
||||
if action == BlockedUserAction.MARK_AS_BLOCKED:
|
||||
success = await self.mark_user_as_blocked(db, user_result.user_id)
|
||||
if success:
|
||||
result.marked_as_blocked += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка пометки {user_result.telegram_id}')
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(i + 1, total)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Ошибка обработки {user_result.telegram_id}: {e}'
|
||||
result.errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
return result
|
||||
+201
-199
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
|
||||
|
||||
@@ -27,8 +28,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
LARGE_BROADCAST_THRESHOLD = 20_000
|
||||
PROGRESS_UPDATE_STEP = 5_000
|
||||
|
||||
# =========================================================================
|
||||
# Telegram rate limits: ~30 msg/sec для бота.
|
||||
# batch_size=25 + 1 sec delay = ~25 msg/sec с запасом.
|
||||
# =========================================================================
|
||||
_TG_BATCH_SIZE = 25
|
||||
_TG_BATCH_DELAY = 1.0 # секунда между батчами
|
||||
_TG_MAX_RETRIES = 3 # retry при FloodWait / transient errors
|
||||
|
||||
# Прогресс обновляется каждые ~500 сообщений ИЛИ раз в 5 секунд (что наступит раньше)
|
||||
_PROGRESS_UPDATE_MESSAGES = 500
|
||||
_PROGRESS_MIN_INTERVAL_SEC = 5.0
|
||||
|
||||
# Email broadcast rate limiting: max 8 emails per second
|
||||
EMAIL_RATE_LIMIT = 8
|
||||
@@ -61,6 +72,14 @@ class EmailBroadcastConfig:
|
||||
initiator_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _EmailRecipient:
|
||||
"""Скалярные данные получателя email (без ORM)."""
|
||||
|
||||
email: str
|
||||
user_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
@@ -136,7 +155,8 @@ class BroadcastService:
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
|
||||
recipient_ids: list[int] = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
@@ -144,45 +164,35 @@ class BroadcastService:
|
||||
logger.error('Запись рассылки %s удалена до запуска', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
broadcast.total_count = len(recipient_ids)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
if not recipient_ids:
|
||||
logger.info('Рассылка %s: получатели не найдены', broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
|
||||
logger.info('Запускаем стабильный режим рассылки для %s получателей', len(recipients))
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_resilient_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
else:
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_standard_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
logger.info(
|
||||
'Рассылка %s: начинаем отправку %d получателям (batch=%d, delay=%.1fs)',
|
||||
broadcast_id,
|
||||
len(recipient_ids),
|
||||
_TG_BATCH_SIZE,
|
||||
_TG_BATCH_DELAY,
|
||||
)
|
||||
|
||||
sent_count, failed_count, cancelled_during_run = await self._send_batched(
|
||||
broadcast_id,
|
||||
recipient_ids,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
|
||||
if cancelled_during_run:
|
||||
logger.info(
|
||||
@@ -211,140 +221,125 @@ class BroadcastService:
|
||||
logger.exception('Критическая ошибка при выполнении рассылки %s: %s', broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async def _fetch_recipients(self, target: str) -> list[int]:
|
||||
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith('custom_'):
|
||||
criteria = target[len('custom_') :]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
users_orm = await get_custom_users(session, criteria)
|
||||
else:
|
||||
users_orm = await get_target_users(session, target)
|
||||
|
||||
async def _run_standard_broadcast(
|
||||
# Извлекаем telegram_id сразу, пока сессия жива.
|
||||
# После выхода из блока ORM-объекты станут detached.
|
||||
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
|
||||
|
||||
async def _send_batched(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
recipient_ids: list[int],
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Базовый режим рассылки для небольших списков."""
|
||||
"""
|
||||
Единый метод рассылки для любого количества получателей.
|
||||
|
||||
Батчинг по _TG_BATCH_SIZE сообщений с _TG_BATCH_DELAY задержкой.
|
||||
Прогресс обновляется каждые _PROGRESS_UPDATE_MESSAGES сообщений.
|
||||
Глобальная пауза при FloodWait.
|
||||
"""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
# Глобальная пауза при FloodWait — все корутины ждут
|
||||
flood_wait_until: float = 0.0
|
||||
last_progress_update: float = 0.0
|
||||
last_progress_count: int = 0
|
||||
|
||||
async def send_single(telegram_id: int) -> bool:
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_TG_MAX_RETRIES):
|
||||
# Глобальная пауза при FloodWait
|
||||
now = asyncio.get_event_loop().time()
|
||||
if flood_wait_until > now:
|
||||
await asyncio.sleep(flood_wait_until - now)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, 'telegram_id', None)
|
||||
if telegram_id is None:
|
||||
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
|
||||
return None
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %s: %s',
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
wait_seconds = e.retry_after + 1
|
||||
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
|
||||
logger.warning(
|
||||
'FloodWait рассылки %s: Telegram просит %d сек (user=%d, попытка %d/%d)',
|
||||
broadcast_id,
|
||||
e.retry_after,
|
||||
telegram_id,
|
||||
exc,
|
||||
attempt + 1,
|
||||
_TG_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
skipped_count = 0
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
except TelegramBadRequest:
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %d (попытка %d/%d): %s',
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_TG_MAX_RETRIES,
|
||||
exc,
|
||||
)
|
||||
if attempt < _TG_MAX_RETRIES - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False
|
||||
|
||||
for i in range(0, len(recipient_ids), _TG_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
batch = recipient_ids[i : i + _TG_BATCH_SIZE]
|
||||
results = await asyncio.gather(
|
||||
*[send_single(tid) for tid in batch],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Email-пользователи - пропускаем без ошибки
|
||||
skipped_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
async def _run_resilient_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(15)
|
||||
|
||||
async def send_single_message(user):
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, 'telegram_id', None)
|
||||
if telegram_id is None:
|
||||
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
|
||||
return None
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %s: %s',
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Email-пользователи - пропускаем без ошибки
|
||||
pass
|
||||
else:
|
||||
if isinstance(result, bool):
|
||||
if result:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
logger.error('Необработанное исключение в рассылке %s: %s', broadcast_id, result)
|
||||
|
||||
# Обновляем прогресс в БД периодически
|
||||
processed = sent_count + failed_count
|
||||
if processed % PROGRESS_UPDATE_STEP == 0:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if (
|
||||
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
|
||||
or now - last_progress_update >= _PROGRESS_MIN_INTERVAL_SEC
|
||||
):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
last_progress_count = processed
|
||||
last_progress_update = now
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
# Задержка между батчами для rate limiting
|
||||
await asyncio.sleep(_TG_BATCH_DELAY)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
@@ -359,37 +354,36 @@ class BroadcastService:
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет одно сообщение.
|
||||
|
||||
НЕ ловит исключения — TelegramRetryAfter, TelegramForbiddenError и др.
|
||||
обрабатываются в вызывающем коде (_send_batched).
|
||||
"""
|
||||
if not self._bot:
|
||||
raise RuntimeError('Телеграм-бот не инициализирован')
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == 'photo':
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == 'video':
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == 'document':
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
media_methods = {
|
||||
'photo': ('photo', self._bot.send_photo),
|
||||
'video': ('video', self._bot.send_video),
|
||||
'document': ('document', self._bot.send_document),
|
||||
}
|
||||
kwarg_name, send_method = media_methods[config.media.type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{kwarg_name: config.media.file_id},
|
||||
caption=caption,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
@@ -617,8 +611,13 @@ class EmailBroadcastService:
|
||||
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_email_recipients(self, target: str) -> list:
|
||||
"""Fetch email recipients based on target filter."""
|
||||
async def _fetch_email_recipients(self, target: str) -> list[_EmailRecipient]:
|
||||
"""
|
||||
Загружает получателей email-рассылки.
|
||||
|
||||
Возвращает список _EmailRecipient (скалярные данные), а не ORM-объектов,
|
||||
чтобы избежать detached state при долгих рассылках.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus, User
|
||||
@@ -632,18 +631,15 @@ class EmailBroadcastService:
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
# All users with verified email
|
||||
query = select(User).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
# Only email-registered users (no telegram)
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
# Telegram users who also have email
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
@@ -651,7 +647,6 @@ class EmailBroadcastService:
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
# Email users with active subscription
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
@@ -662,7 +657,6 @@ class EmailBroadcastService:
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
# Email users with expired subscription
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
@@ -681,8 +675,8 @@ class EmailBroadcastService:
|
||||
logger.warning('Unknown email target filter: %s', target)
|
||||
return []
|
||||
|
||||
# Load users in batches
|
||||
users: list = []
|
||||
# Загружаем батчами и извлекаем скаляры сразу
|
||||
recipients: list[_EmailRecipient] = []
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
|
||||
@@ -693,108 +687,116 @@ class EmailBroadcastService:
|
||||
if not batch:
|
||||
break
|
||||
|
||||
users.extend(batch)
|
||||
for user in batch:
|
||||
email = user.email
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Формируем имя пользователя
|
||||
user_name = user.username
|
||||
if not user_name:
|
||||
user_name = user.first_name or ''
|
||||
if last_name := user.last_name:
|
||||
user_name = f'{user_name} {last_name}'.strip()
|
||||
if not user_name:
|
||||
user_name = email.split('@')[0]
|
||||
|
||||
recipients.append(_EmailRecipient(email=email, user_name=user_name))
|
||||
|
||||
offset += batch_size
|
||||
|
||||
return users
|
||||
return recipients
|
||||
|
||||
async def _send_emails(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
recipients: list[_EmailRecipient],
|
||||
config: EmailBroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Send emails with rate limiting."""
|
||||
"""
|
||||
Отправляет email-рассылку с rate limiting.
|
||||
|
||||
Использует run_in_executor для синхронного SMTP, ограничивая
|
||||
параллельность семафором EMAIL_RATE_LIMIT.
|
||||
"""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
last_progress_count = 0
|
||||
last_progress_time: float = 0.0
|
||||
|
||||
# Semaphore for rate limiting (max EMAIL_RATE_LIMIT concurrent sends)
|
||||
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
|
||||
|
||||
async def send_single_email(user) -> bool | None:
|
||||
"""Send single email with rate limiting."""
|
||||
async def send_single_email(recipient: _EmailRecipient) -> bool | None:
|
||||
"""Отправляет один email."""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return None
|
||||
|
||||
email = getattr(user, 'email', None)
|
||||
if not email:
|
||||
return None
|
||||
|
||||
# Render template with variables
|
||||
html_content = self._render_template(config.email_html_content, user)
|
||||
subject = self._render_template(config.email_subject, user)
|
||||
html_content = self._render_template(config.email_html_content, recipient)
|
||||
subject = self._render_template(config.email_subject, recipient)
|
||||
|
||||
try:
|
||||
# Run sync email send in executor to not block event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
None,
|
||||
self._email_service.send_email,
|
||||
email,
|
||||
recipient.email,
|
||||
subject,
|
||||
html_content,
|
||||
)
|
||||
return success
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Error sending email broadcast %s to %s: %s',
|
||||
'Ошибка отправки email рассылки %s на %s: %s',
|
||||
broadcast_id,
|
||||
email,
|
||||
recipient.email,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + EMAIL_BATCH_SIZE]
|
||||
tasks = [send_single_email(user) for user in batch]
|
||||
tasks = [send_single_email(r) for r in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Skipped (cancelled or no email)
|
||||
pass
|
||||
pass # Cancelled or skipped
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Update progress periodically
|
||||
# Обновляем прогресс периодически
|
||||
processed = sent_count + failed_count
|
||||
if processed % PROGRESS_UPDATE_STEP == 0 or i + EMAIL_BATCH_SIZE >= len(recipients):
|
||||
now = asyncio.get_event_loop().time()
|
||||
if (
|
||||
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
|
||||
or now - last_progress_time >= _PROGRESS_MIN_INTERVAL_SEC
|
||||
or i + EMAIL_BATCH_SIZE >= len(recipients)
|
||||
):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
last_progress_count = processed
|
||||
last_progress_time = now
|
||||
|
||||
# Rate limiting delay between batches (ensure ~8 emails/sec)
|
||||
# Rate limiting: ~8 emails/sec
|
||||
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
def _render_template(self, template: str, user) -> str:
|
||||
"""Render template with user variables."""
|
||||
@staticmethod
|
||||
def _render_template(template: str, recipient: _EmailRecipient) -> str:
|
||||
"""Подставляет переменные в шаблон email."""
|
||||
if not template:
|
||||
return template
|
||||
|
||||
# Get user name
|
||||
user_name = getattr(user, 'username', None)
|
||||
if not user_name:
|
||||
user_name = getattr(user, 'first_name', None) or ''
|
||||
if last_name := getattr(user, 'last_name', None):
|
||||
user_name = f'{user_name} {last_name}'.strip()
|
||||
if not user_name:
|
||||
user_name = getattr(user, 'email', '').split('@')[0] if getattr(user, 'email', None) else 'User'
|
||||
|
||||
email = getattr(user, 'email', '') or ''
|
||||
|
||||
# Replace template variables
|
||||
result = template.replace('{{user_name}}', user_name)
|
||||
result = result.replace('{{email}}', email)
|
||||
|
||||
result = template.replace('{{user_name}}', recipient.user_name)
|
||||
result = result.replace('{{email}}', recipient.email)
|
||||
return result
|
||||
|
||||
async def _mark_finished(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Service for blocking disposable/temporary email domains."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DisposableEmailService:
|
||||
"""
|
||||
Downloads and caches a list of disposable email domains from GitHub.
|
||||
|
||||
Domains are stored in a frozenset for O(1) thread-safe lookups.
|
||||
The list is refreshed every 24 hours via an asyncio background task.
|
||||
If the download fails, the service falls back to an empty set (no blocking).
|
||||
"""
|
||||
|
||||
DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt'
|
||||
UPDATE_INTERVAL_HOURS = 24
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._domains: frozenset[str] = frozenset()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._last_updated: datetime | None = None
|
||||
self._domain_count: int = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Load domains and start periodic refresh task."""
|
||||
await self._update_domains()
|
||||
self._task = asyncio.create_task(self._periodic_loop())
|
||||
logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel periodic refresh task."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info('DisposableEmailService stopped')
|
||||
|
||||
async def _update_domains(self) -> None:
|
||||
"""Fetch domains.txt from GitHub and swap the in-memory set."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
'Failed to fetch disposable domains: HTTP %d',
|
||||
resp.status,
|
||||
)
|
||||
return
|
||||
|
||||
text = await resp.text()
|
||||
|
||||
domains = frozenset(
|
||||
line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith('#')
|
||||
)
|
||||
|
||||
self._domains = domains
|
||||
self._domain_count = len(domains)
|
||||
self._last_updated = datetime.now(UTC)
|
||||
logger.info('Disposable email domains updated: %d domains', self._domain_count)
|
||||
|
||||
except Exception:
|
||||
logger.exception('Error updating disposable email domains')
|
||||
|
||||
async def _periodic_loop(self) -> None:
|
||||
"""Sleep then refresh, repeating forever until cancelled."""
|
||||
while True:
|
||||
await asyncio.sleep(self.UPDATE_INTERVAL_HOURS * 3600)
|
||||
await self._update_domains()
|
||||
|
||||
def is_disposable(self, email: str) -> bool:
|
||||
"""Check if the email uses a disposable domain.
|
||||
|
||||
Returns False when the feature is disabled via settings.
|
||||
"""
|
||||
if not getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True):
|
||||
return False
|
||||
|
||||
if not self._domains:
|
||||
return False
|
||||
|
||||
try:
|
||||
domain = email.rsplit('@', 1)[1].lower()
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
return domain in self._domains
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Return service status for monitoring / health checks."""
|
||||
return {
|
||||
'enabled': getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True),
|
||||
'domain_count': self._domain_count,
|
||||
'last_updated': self._last_updated.isoformat() if self._last_updated else None,
|
||||
'running': self._task is not None and not self._task.done(),
|
||||
}
|
||||
|
||||
|
||||
disposable_email_service = DisposableEmailService()
|
||||
@@ -246,7 +246,7 @@ class KassaAiService:
|
||||
}
|
||||
params['signature'] = self._generate_hmac_signature(params)
|
||||
|
||||
logger.debug(f'KassaAI get_order_status: order_id={order_id}')
|
||||
logger.info(f'KassaAI get_order_status: order_id={order_id}')
|
||||
|
||||
try:
|
||||
async with (
|
||||
@@ -259,7 +259,7 @@ class KassaAiService:
|
||||
) as response,
|
||||
):
|
||||
text = await response.text()
|
||||
logger.debug(f'KassaAI get_order_status response: {text}')
|
||||
logger.info(f'KassaAI get_order_status response: {text}')
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f'KassaAI API connection error: {e}')
|
||||
|
||||
@@ -219,17 +219,8 @@ class MaintenanceService:
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
|
||||
|
||||
Система будет следить за доступностью API.""",
|
||||
'info',
|
||||
)
|
||||
# Сообщение о запуске мониторинга убрано - теперь используется
|
||||
# единое стартовое уведомление через StartupNotificationService
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ from app.services.notification_settings_service import NotificationSettingsServi
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.cache import cache
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
from app.utils.subscription_utils import (
|
||||
@@ -69,6 +70,10 @@ from app.utils.subscription_utils import (
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
|
||||
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -966,7 +971,8 @@ class MonitoringService:
|
||||
selectinload(Subscription.user).options(
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
@@ -980,6 +986,16 @@ class MonitoringService:
|
||||
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
# Суточные подписки имеют свой собственный механизм продления
|
||||
# (DailySubscriptionService), глобальный autopay на них не распространяется
|
||||
if sub.tariff and getattr(sub.tariff, 'is_daily', False):
|
||||
logger.debug(
|
||||
'Пропускаем суточную подписку %s (тариф %s) в глобальном autopay',
|
||||
sub.id,
|
||||
sub.tariff.name,
|
||||
)
|
||||
continue
|
||||
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= min(sub.autopay_days_before, 3):
|
||||
autopay_subscriptions.append(sub)
|
||||
@@ -1056,13 +1072,50 @@ class MonitoringService:
|
||||
logger.warning(f'💳 Ошибка списания средств для автопродления пользователя {user_identifier}')
|
||||
else:
|
||||
failed_count += 1
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Недостаточно средств на балансе',
|
||||
|
||||
# Проверяем кулдаун уведомления через Redis, чтобы не спамить
|
||||
# при каждом срабатывании мониторинга
|
||||
cooldown_key = f'autopay_insufficient_balance_notified:{user.id}'
|
||||
should_notify = True
|
||||
|
||||
try:
|
||||
if await cache.exists(cooldown_key):
|
||||
should_notify = False
|
||||
logger.debug(
|
||||
'💳 Пропуск уведомления о недостаточном балансе для пользователя %s — кулдаун активен',
|
||||
user_identifier,
|
||||
)
|
||||
except Exception as redis_err:
|
||||
# Fallback: если Redis недоступен — отправляем уведомление
|
||||
logger.warning(
|
||||
'⚠️ Ошибка проверки кулдауна в Redis для пользователя %s: %s. Отправляем уведомление.',
|
||||
user_identifier,
|
||||
redis_err,
|
||||
)
|
||||
|
||||
if should_notify:
|
||||
if user.telegram_id and self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
elif not user.telegram_id:
|
||||
await notification_delivery_service.notify_autopay_failed(
|
||||
user=user,
|
||||
reason='Недостаточно средств на балансе',
|
||||
)
|
||||
|
||||
# Ставим ключ кулдауна после отправки
|
||||
try:
|
||||
await cache.set(
|
||||
cooldown_key,
|
||||
1,
|
||||
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
|
||||
)
|
||||
except Exception as redis_err:
|
||||
logger.warning(
|
||||
'⚠️ Не удалось установить кулдаун в Redis для пользователя %s: %s',
|
||||
user_identifier,
|
||||
redis_err,
|
||||
)
|
||||
|
||||
logger.warning(f'💳 Недостаточно средств для автопродления у пользователя {user_identifier}')
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
|
||||
@@ -223,8 +223,8 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.error('Пользователь не найден: id=%s', payment.user_id)
|
||||
return False
|
||||
|
||||
# Add balance
|
||||
await add_user_balance(db, user.id, amount_kopeks)
|
||||
# Add balance (без автоматической транзакции - создадим ниже с external_id)
|
||||
await add_user_balance(db, user, amount_kopeks, create_transaction=False)
|
||||
|
||||
# Create transaction record
|
||||
from app.database.crud.transaction import create_transaction
|
||||
@@ -232,7 +232,7 @@ class CloudPaymentsPaymentMixin:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type_=TransactionType.DEPOSIT,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
payment_method=PaymentMethod.CLOUDPAYMENTS,
|
||||
@@ -350,11 +350,17 @@ class CloudPaymentsPaymentMixin:
|
||||
transaction: Any,
|
||||
) -> None:
|
||||
"""Send success notification to user via Telegram."""
|
||||
from app.bot import bot
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
if not bot:
|
||||
return
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
@@ -400,10 +406,16 @@ class CloudPaymentsPaymentMixin:
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Send failure notification to user via Telegram."""
|
||||
from app.bot import bot
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
if not bot:
|
||||
return
|
||||
from app.config import settings
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
|
||||
|
||||
|
||||
@@ -69,8 +69,13 @@ class KassaAiPaymentMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
# Генерируем уникальный order_id
|
||||
order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}'
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.KASSA_AI_CURRENCY
|
||||
|
||||
@@ -487,3 +492,91 @@ class KassaAiPaymentMixin:
|
||||
except Exception as e:
|
||||
logger.exception('KassaAI: ошибка проверки статуса: %s', e)
|
||||
return None
|
||||
|
||||
async def get_kassa_ai_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Проверяет статус платежа KassaAI по локальному ID через API.
|
||||
Если платёж оплачен — автоматически начисляет баланс.
|
||||
"""
|
||||
logger.info('KassaAI: checking payment status for id=%s', local_payment_id)
|
||||
kassa_ai_crud = import_module('app.database.crud.kassa_ai')
|
||||
|
||||
payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning('KassaAI payment not found: id=%s', local_payment_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
if not settings.KASSA_AI_API_KEY:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
try:
|
||||
# Запрашиваем статус заказа в KassaAI (api.fk.life)
|
||||
response = await kassa_ai_service.get_order_status(payment.order_id)
|
||||
|
||||
# KassaAI возвращает список заказов (как Freekassa)
|
||||
orders = response.get('orders', [])
|
||||
target_order = None
|
||||
|
||||
# Ищем наш заказ в списке
|
||||
for order in orders:
|
||||
order_key = str(order.get('merchant_order_id') or order.get('paymentId'))
|
||||
if order_key == str(payment.order_id):
|
||||
target_order = order
|
||||
break
|
||||
|
||||
if target_order:
|
||||
# Статус 1 = Оплачен (как в Freekassa)
|
||||
kai_status = int(target_order.get('status', 0))
|
||||
|
||||
if kai_status == 1:
|
||||
logger.info('KassaAI payment %s confirmed via API', payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'kai_order_data': target_order,
|
||||
}
|
||||
|
||||
# ID заказа на стороне KassaAI
|
||||
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
|
||||
|
||||
# Обновляем статус
|
||||
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
kassa_ai_order_id=kai_intid,
|
||||
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем (начисляем баланс)
|
||||
await self._finalize_kassa_ai_payment(
|
||||
db,
|
||||
payment,
|
||||
intid=kai_intid,
|
||||
trigger='api_check',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error checking KassaAI payment status: %s', e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
@@ -100,6 +100,17 @@ class WataPaymentMixin:
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
# Добавляем идентификатор плательщика (telegram_id или email) в описание
|
||||
try:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
if user:
|
||||
if user.telegram_id:
|
||||
description = f'{description} | ID: {user.telegram_id}'
|
||||
elif user.email:
|
||||
description = f'{description} | {user.email}'
|
||||
except Exception as error:
|
||||
logger.debug('Не удалось получить данные пользователя для описания WATA: %s', error)
|
||||
|
||||
order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}'
|
||||
|
||||
try:
|
||||
|
||||
@@ -1328,6 +1328,27 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before creating FK-linked record
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Webhook YooKassa %s: user_id=%s не найден в БД, пропускаем восстановление платежа',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Webhook YooKassa %s: не удалось проверить user_id=%s: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
amount_info = event_object.get('amount') or {}
|
||||
amount_value = amount_info.get('value')
|
||||
currency = (amount_info.get('currency') or 'RUB').upper()
|
||||
|
||||
@@ -164,42 +164,78 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None:
|
||||
"""Initialize payment method configs if they don't exist yet.
|
||||
|
||||
Called on startup to seed defaults from env vars.
|
||||
Also adds any missing methods that were added after initial setup.
|
||||
"""
|
||||
count_result = await db.execute(select(func.count()).select_from(PaymentMethodConfig))
|
||||
count = count_result.scalar() or 0
|
||||
# Get existing method IDs
|
||||
existing_result = await db.execute(select(PaymentMethodConfig.method_id))
|
||||
existing_method_ids = set(existing_result.scalars().all())
|
||||
|
||||
if count > 0:
|
||||
return # Already initialized
|
||||
if not existing_method_ids:
|
||||
# First-time initialization
|
||||
logger.info('Initializing payment method configurations from env vars...')
|
||||
defaults = _get_method_defaults()
|
||||
|
||||
logger.info('Initializing payment method configurations from env vars...')
|
||||
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
|
||||
method_def = defaults.get(method_id, {})
|
||||
is_configured = method_def.get('is_configured', False)
|
||||
sub_options = None
|
||||
available = method_def.get('available_sub_options')
|
||||
if available:
|
||||
# Enable all sub-options by default
|
||||
sub_options = {opt['id']: True for opt in available}
|
||||
|
||||
config = PaymentMethodConfig(
|
||||
method_id=method_id,
|
||||
sort_order=idx,
|
||||
is_enabled=is_configured,
|
||||
display_name=None,
|
||||
sub_options=sub_options,
|
||||
min_amount_kopeks=None,
|
||||
max_amount_kopeks=None,
|
||||
user_type_filter='all',
|
||||
first_topup_filter='any',
|
||||
promo_group_filter_mode='all',
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
|
||||
return
|
||||
|
||||
# Add missing methods (for cases when new methods are added to code)
|
||||
defaults = _get_method_defaults()
|
||||
missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids]
|
||||
|
||||
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
|
||||
method_def = defaults.get(method_id, {})
|
||||
is_configured = method_def.get('is_configured', False)
|
||||
sub_options = None
|
||||
available = method_def.get('available_sub_options')
|
||||
if available:
|
||||
# Enable all sub-options by default
|
||||
sub_options = {opt['id']: True for opt in available}
|
||||
if missing_methods:
|
||||
logger.info(f'Adding missing payment methods: {missing_methods}')
|
||||
# Get max sort_order to append new methods at the end
|
||||
max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order)))
|
||||
max_order = max_order_result.scalar() or 0
|
||||
|
||||
config = PaymentMethodConfig(
|
||||
method_id=method_id,
|
||||
sort_order=idx,
|
||||
is_enabled=is_configured,
|
||||
display_name=None,
|
||||
sub_options=sub_options,
|
||||
min_amount_kopeks=None,
|
||||
max_amount_kopeks=None,
|
||||
user_type_filter='all',
|
||||
first_topup_filter='any',
|
||||
promo_group_filter_mode='all',
|
||||
)
|
||||
db.add(config)
|
||||
for idx, method_id in enumerate(missing_methods, start=max_order + 1):
|
||||
method_def = defaults.get(method_id, {})
|
||||
is_configured = method_def.get('is_configured', False)
|
||||
sub_options = None
|
||||
available = method_def.get('available_sub_options')
|
||||
if available:
|
||||
sub_options = {opt['id']: True for opt in available}
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
|
||||
config = PaymentMethodConfig(
|
||||
method_id=method_id,
|
||||
sort_order=idx,
|
||||
is_enabled=is_configured,
|
||||
display_name=None,
|
||||
sub_options=sub_options,
|
||||
min_amount_kopeks=None,
|
||||
max_amount_kopeks=None,
|
||||
user_type_filter='all',
|
||||
first_topup_filter='any',
|
||||
promo_group_filter_mode='all',
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Added {len(missing_methods)} missing payment method(s).')
|
||||
|
||||
|
||||
# ============ CRUD ============
|
||||
|
||||
@@ -71,6 +71,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.PLATEGA,
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -87,6 +88,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
# WATA removed - API returns 429 "Use webhook – polling is rate-limited".
|
||||
# Payments are processed via webhook (wata_webhook.py).
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -955,6 +957,9 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.FREEKASSA:
|
||||
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
|
||||
payment = result.get('payment') if result else None
|
||||
elif method == PaymentMethod.KASSA_AI:
|
||||
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
logger.warning('Manual check requested for unsupported method %s', method)
|
||||
return None
|
||||
|
||||
@@ -123,7 +123,15 @@ async def broadcast_pinned_message(
|
||||
db: AsyncSession,
|
||||
pinned_message: PinnedMessage,
|
||||
) -> tuple[int, int]:
|
||||
users: list[User] = []
|
||||
"""
|
||||
Рассылает закреплённое сообщение всем активным пользователям.
|
||||
|
||||
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой рассылки,
|
||||
чтобы избежать обращения к ORM-объектам после истечения таймаута
|
||||
соединения с БД.
|
||||
"""
|
||||
# Собираем telegram_id всех активных пользователей
|
||||
recipient_telegram_ids: list[int] = []
|
||||
offset = 0
|
||||
batch_size = 5000
|
||||
|
||||
@@ -138,27 +146,26 @@ async def broadcast_pinned_message(
|
||||
if not batch:
|
||||
break
|
||||
|
||||
users.extend(batch)
|
||||
# Извлекаем только telegram_id, фильтруем email-only пользователей
|
||||
for user in batch:
|
||||
if user.telegram_id is not None:
|
||||
recipient_telegram_ids.append(user.telegram_id)
|
||||
|
||||
offset += batch_size
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
|
||||
async def send_to_user(user: User) -> None:
|
||||
async def send_to_telegram_id(telegram_id: int) -> None:
|
||||
nonlocal sent_count, failed_count
|
||||
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
failed_count += 1
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
success = await _send_and_pin_message(
|
||||
bot,
|
||||
user.telegram_id,
|
||||
telegram_id,
|
||||
pinned_message,
|
||||
)
|
||||
if success:
|
||||
@@ -170,22 +177,22 @@ async def broadcast_pinned_message(
|
||||
delay = min(retry_error.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
'RetryAfter for user %s, waiting %s seconds',
|
||||
user.telegram_id,
|
||||
telegram_id,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Ошибка отправки закрепленного сообщения пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
telegram_id,
|
||||
send_error,
|
||||
)
|
||||
failed_count += 1
|
||||
break
|
||||
|
||||
for i in range(0, len(users), 30):
|
||||
batch = users[i : i + 30]
|
||||
tasks = [send_to_user(user) for user in batch]
|
||||
for i in range(0, len(recipient_telegram_ids), 30):
|
||||
batch = recipient_telegram_ids[i : i + 30]
|
||||
tasks = [send_to_telegram_id(tid) for tid in batch]
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@@ -196,11 +203,19 @@ async def unpin_active_pinned_message(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""
|
||||
Открепляет активное сообщение у всех пользователей.
|
||||
|
||||
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой операции,
|
||||
чтобы избежать обращения к ORM-объектам после истечения таймаута
|
||||
соединения с БД.
|
||||
"""
|
||||
pinned_message = await deactivate_active_pinned_message(db)
|
||||
if not pinned_message:
|
||||
return 0, 0, False
|
||||
|
||||
users: list[User] = []
|
||||
# Собираем telegram_id всех активных пользователей
|
||||
recipient_telegram_ids: list[int] = []
|
||||
offset = 0
|
||||
batch_size = 5000
|
||||
|
||||
@@ -215,24 +230,23 @@ async def unpin_active_pinned_message(
|
||||
if not batch:
|
||||
break
|
||||
|
||||
users.extend(batch)
|
||||
# Извлекаем только telegram_id, фильтруем email-only пользователей
|
||||
for user in batch:
|
||||
if user.telegram_id is not None:
|
||||
recipient_telegram_ids.append(user.telegram_id)
|
||||
|
||||
offset += batch_size
|
||||
|
||||
unpinned_count = 0
|
||||
failed_count = 0
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def unpin_for_user(user: User) -> None:
|
||||
async def unpin_for_telegram_id(telegram_id: int) -> None:
|
||||
nonlocal unpinned_count, failed_count
|
||||
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
failed_count += 1
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
success = await _unpin_message_for_user(bot, user.telegram_id)
|
||||
success = await _unpin_message_for_user(bot, telegram_id)
|
||||
if success:
|
||||
unpinned_count += 1
|
||||
else:
|
||||
@@ -241,22 +255,30 @@ async def unpin_active_pinned_message(
|
||||
delay = min(retry_error.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
'RetryAfter while unpinning for user %s, waiting %s seconds',
|
||||
user.telegram_id,
|
||||
telegram_id,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
await unpin_for_user(user)
|
||||
# Повторная попытка после ожидания
|
||||
try:
|
||||
success = await _unpin_message_for_user(bot, telegram_id)
|
||||
if success:
|
||||
unpinned_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка открепления сообщения у пользователя %s: %s',
|
||||
user.telegram_id,
|
||||
telegram_id,
|
||||
error,
|
||||
)
|
||||
failed_count += 1
|
||||
|
||||
for i in range(0, len(users), 40):
|
||||
batch = users[i : i + 40]
|
||||
tasks = [unpin_for_user(user) for user in batch]
|
||||
for i in range(0, len(recipient_telegram_ids), 40):
|
||||
batch = recipient_telegram_ids[i : i + 40]
|
||||
tasks = [unpin_for_telegram_id(tid) for tid in batch]
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.promocode import (
|
||||
check_user_promocode_usage,
|
||||
create_promocode_use,
|
||||
get_active_discount_promocode_for_user,
|
||||
get_promocode_by_code,
|
||||
)
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
@@ -300,3 +301,102 @@ class PromoCodeService:
|
||||
effects.append('ℹ️ У вас уже есть активная подписка')
|
||||
|
||||
return '\n'.join(effects) if effects else '✅ Промокод активирован'
|
||||
|
||||
async def deactivate_discount_promocode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
admin_initiated: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Деактивирует активный промокод на процентную скидку у пользователя.
|
||||
|
||||
Действия:
|
||||
- Сбрасывает promo_offer_discount_percent / source / expires_at на пользователе
|
||||
- Удаляет запись PromoCodeUse (чтобы промокод мог быть повторно использован, если max_uses > current_uses)
|
||||
- Декрементирует current_uses на промокоде
|
||||
- Если промокод назначил промогруппу -- снимает её с пользователя
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
admin_initiated: True если деактивацию инициировал админ
|
||||
|
||||
Returns:
|
||||
dict с ключами success, error (опционально), deactivated_code (опционально)
|
||||
"""
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'user_not_found'}
|
||||
|
||||
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
|
||||
if current_discount <= 0 or not source or not source.startswith('promocode:'):
|
||||
return {'success': False, 'error': 'no_active_discount_promocode'}
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||||
# Если скидка уже истекла по времени -- тоже нечего деактивировать
|
||||
if expires_at is not None and expires_at <= datetime.utcnow():
|
||||
# Просто зачистим протухшие данные
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {'success': False, 'error': 'discount_already_expired'}
|
||||
|
||||
promocode, promo_use = await get_active_discount_promocode_for_user(db, user_id)
|
||||
|
||||
deactivated_code = source.split(':', 1)[1]
|
||||
|
||||
# 1. Сбрасываем скидку на пользователе
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
# 2. Откатываем использование промокода (если нашли запись)
|
||||
if promocode and promo_use:
|
||||
await db.delete(promo_use)
|
||||
if promocode.current_uses > 0:
|
||||
promocode.current_uses -= 1
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
|
||||
# 3. Если промокод назначал промогруппу -- снимаем её
|
||||
if promocode.promo_group_id:
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
)
|
||||
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
if has_group:
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
|
||||
logger.info(
|
||||
f'Снята промогруппа ID {promocode.promo_group_id} у пользователя '
|
||||
f'{self._format_user_log(user)} при деактивации промокода {deactivated_code}'
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
initiator = 'администратором' if admin_initiated else 'пользователем'
|
||||
logger.info(
|
||||
f'Промокод {deactivated_code} (скидка {current_discount}%) деактивирован '
|
||||
f'{initiator} для пользователя {self._format_user_log(user)}'
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'deactivated_code': deactivated_code,
|
||||
'discount_percent': current_discount,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка деактивации промокода для пользователя {user_id}: {e}')
|
||||
await db.rollback()
|
||||
return {'success': False, 'error': 'server_error'}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,19 +151,20 @@ class RemnaWaveService:
|
||||
elif not api_key:
|
||||
self._config_error = 'REMNAWAVE_API_KEY не настроен'
|
||||
|
||||
self.api: RemnaWaveAPI | None
|
||||
if self._config_error:
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get('secret_key'),
|
||||
username=auth_params.get('username'),
|
||||
password=auth_params.get('password'),
|
||||
caddy_token=auth_params.get('caddy_token'),
|
||||
auth_type=auth_params.get('auth_type') or 'api_key',
|
||||
)
|
||||
# Сохраняем параметры для создания новых экземпляров API клиента
|
||||
# (каждый вызов get_api_client создаёт свой экземпляр, чтобы
|
||||
# параллельные корутины не перезаписывали друг другу aiohttp-сессию)
|
||||
self._api_kwargs: dict | None = None
|
||||
if not self._config_error:
|
||||
self._api_kwargs = {
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'secret_key': auth_params.get('secret_key'),
|
||||
'username': auth_params.get('username'),
|
||||
'password': auth_params.get('password'),
|
||||
'caddy_token': auth_params.get('caddy_token'),
|
||||
'auth_type': auth_params.get('auth_type') or 'api_key',
|
||||
}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -174,7 +175,7 @@ class RemnaWaveService:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.is_configured or self.api is None:
|
||||
if not self.is_configured or self._api_kwargs is None:
|
||||
raise RemnaWaveConfigurationError(self._config_error or 'RemnaWave API не настроен')
|
||||
|
||||
def _ensure_user_remnawave_uuid(
|
||||
@@ -228,8 +229,9 @@ class RemnaWaveService:
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
assert self._api_kwargs is not None
|
||||
api = RemnaWaveAPI(**self._api_kwargs)
|
||||
async with api:
|
||||
yield api
|
||||
|
||||
def _now_utc(self) -> datetime:
|
||||
@@ -1439,12 +1441,14 @@ class RemnaWaveService:
|
||||
|
||||
# Используем один API клиент для всех операций сброса HWID
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
try:
|
||||
hwid_api_client = self.get_api_client()
|
||||
await hwid_api_client.__aenter__()
|
||||
hwid_api_cm = self.get_api_client()
|
||||
hwid_api_client = await hwid_api_cm.__aenter__()
|
||||
except Exception as api_init_error:
|
||||
logger.warning(f'⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}')
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
|
||||
try:
|
||||
for telegram_id, db_user in users_to_deactivate:
|
||||
@@ -1565,9 +1569,9 @@ class RemnaWaveService:
|
||||
|
||||
finally:
|
||||
# Закрываем API клиент
|
||||
if hwid_api_client:
|
||||
if hwid_api_cm:
|
||||
try:
|
||||
await hwid_api_client.__aexit__(None, None, None)
|
||||
await hwid_api_cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Сервис стартового уведомления бота.
|
||||
|
||||
Отправляет красивое сообщение с информацией о системе при запуске бота.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import Subscription, SubscriptionStatus, Ticket, TicketStatus, User, UserStatus
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Константы
|
||||
VERSION_ENV_VAR: Final[str] = 'VERSION'
|
||||
DEFAULT_VERSION: Final[str] = 'dev'
|
||||
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
|
||||
|
||||
# Форматирование
|
||||
KOPEKS_IN_RUBLE: Final[int] = 100
|
||||
MILLION: Final[int] = 1_000_000
|
||||
THOUSAND: Final[int] = 1_000
|
||||
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
|
||||
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
|
||||
REPORT_SEPARATOR_WIDTH: Final[int] = 50
|
||||
|
||||
# Лимиты сообщений
|
||||
CRASH_ERROR_MESSAGE_MAX_LENGTH: Final[int] = 1000
|
||||
CRASH_ERROR_PREVIEW_LENGTH: Final[int] = 200
|
||||
|
||||
# URL-ы
|
||||
GITHUB_BOT_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot'
|
||||
GITHUB_CABINET_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
COMMUNITY_URL: Final[str] = 'https://t.me/+wTdMtSWq8YdmZmVi'
|
||||
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
|
||||
|
||||
# Ключевые слова для определения типа ошибки
|
||||
WEBHOOK_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('webhook', 'failed to resolve host')
|
||||
DATABASE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('database', 'postgres', 'connection refused')
|
||||
REDIS_ERROR_KEYWORD: Final[str] = 'redis'
|
||||
REMNAWAVE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('remnawave', 'panel')
|
||||
AUTH_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('unauthorized', 'bot token')
|
||||
INLINE_BUTTON_URL_ERROR_KEYWORDS: Final[tuple[str, ...]] = (
|
||||
'web app url',
|
||||
'url host is empty',
|
||||
'unsupported url protocol',
|
||||
'button url',
|
||||
)
|
||||
|
||||
|
||||
class StartupNotificationService:
|
||||
"""Сервис для отправки стартового уведомления в админский чат."""
|
||||
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Получает версию из переменной окружения VERSION."""
|
||||
version = os.getenv(VERSION_ENV_VAR, '').strip()
|
||||
if version:
|
||||
return version
|
||||
return DEFAULT_VERSION
|
||||
|
||||
async def _get_users_count(self) -> int:
|
||||
"""Получает количество активных пользователей в базе."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value))
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения количества пользователей: %s', e)
|
||||
return 0
|
||||
|
||||
async def _get_total_balance(self) -> int:
|
||||
"""Получает сумму балансов всех пользователей в копейках."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(User.balance_kopeks), 0)).where(
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения суммы балансов: %s', e)
|
||||
return 0
|
||||
|
||||
async def _get_open_tickets_count(self) -> int:
|
||||
"""Получает количество открытых тикетов."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value))
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения количества открытых тикетов: %s', e)
|
||||
return 0
|
||||
|
||||
async def _get_paid_subscriptions_count(self) -> int:
|
||||
"""Получает количество платных подписок (не триальных, активных)."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.is_trial == False,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения количества платных подписок: %s', e)
|
||||
return 0
|
||||
|
||||
async def _get_trial_subscriptions_count(self) -> int:
|
||||
"""Получает количество триальных подписок."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial == True))
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения количества триальных подписок: %s', e)
|
||||
return 0
|
||||
|
||||
async def _check_remnawave_connection(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет соединение с панелью Remnawave.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (is_connected, status_message)
|
||||
"""
|
||||
try:
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
base_url = (auth_params.get('base_url') or '').strip()
|
||||
api_key = (auth_params.get('api_key') or '').strip()
|
||||
|
||||
if not base_url or not api_key:
|
||||
return False, 'Не настроен'
|
||||
|
||||
secret_key = (auth_params.get('secret_key') or '').strip() or None
|
||||
username = (auth_params.get('username') or '').strip() or None
|
||||
password = (auth_params.get('password') or '').strip() or None
|
||||
caddy_token = (auth_params.get('caddy_token') or '').strip() or None
|
||||
auth_type = (auth_params.get('auth_type') or DEFAULT_AUTH_TYPE).strip()
|
||||
|
||||
api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password,
|
||||
caddy_token=caddy_token,
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
if is_connected:
|
||||
return True, 'Подключено'
|
||||
return False, 'Недоступна'
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка проверки соединения с Remnawave: %s', e)
|
||||
return False, 'Ошибка подключения'
|
||||
|
||||
def _format_balance(self, kopeks: int) -> str:
|
||||
"""Форматирует баланс в рублях."""
|
||||
rubles = kopeks / KOPEKS_IN_RUBLE
|
||||
if rubles >= MILLION:
|
||||
return f'{rubles / MILLION:.2f}M RUB'
|
||||
if rubles >= THOUSAND:
|
||||
return f'{rubles / THOUSAND:.1f}K RUB'
|
||||
return f'{rubles:.2f} RUB'
|
||||
|
||||
async def send_startup_notification(self) -> bool:
|
||||
"""
|
||||
Отправляет стартовое уведомление в админский чат.
|
||||
|
||||
Returns:
|
||||
bool: True если сообщение отправлено успешно
|
||||
"""
|
||||
if not self.enabled or not self.chat_id:
|
||||
logger.debug('Стартовое уведомление отключено или chat_id не задан')
|
||||
return False
|
||||
|
||||
try:
|
||||
version = self._get_version()
|
||||
users_count = await self._get_users_count()
|
||||
total_balance_kopeks = await self._get_total_balance()
|
||||
open_tickets_count = await self._get_open_tickets_count()
|
||||
paid_subscriptions_count = await self._get_paid_subscriptions_count()
|
||||
trial_subscriptions_count = await self._get_trial_subscriptions_count()
|
||||
remnawave_connected, remnawave_status = await self._check_remnawave_connection()
|
||||
|
||||
# Иконка статуса Remnawave
|
||||
remnawave_icon = '🟢' if remnawave_connected else '🔴'
|
||||
|
||||
# Формируем системную информацию для blockquote
|
||||
system_info_lines = [
|
||||
f'Версия: {version}',
|
||||
f'Пользователей: {users_count:,}'.replace(',', ' '),
|
||||
f'Сумма балансов: {self._format_balance(total_balance_kopeks)}',
|
||||
f'Платных подписок: {paid_subscriptions_count:,}'.replace(',', ' '),
|
||||
f'Триальных подписок: {trial_subscriptions_count:,}'.replace(',', ' '),
|
||||
f'Открытых тикетов: {open_tickets_count:,}'.replace(',', ' '),
|
||||
f'{remnawave_icon} Remnawave: {remnawave_status}',
|
||||
]
|
||||
system_info = '\n'.join(system_info_lines)
|
||||
|
||||
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
|
||||
|
||||
message = (
|
||||
f'<b>Remnawave Bedolaga Bot</b>\n\n'
|
||||
f'✅ Бот успешно запущен\n\n'
|
||||
f'<blockquote expandable>{system_info}</blockquote>\n\n'
|
||||
f'<i>{timestamp}</i>'
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='Поставить звезду',
|
||||
url=GITHUB_BOT_URL,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='Вебкабинет',
|
||||
url=GITHUB_CABINET_URL,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='Сообщество',
|
||||
url=COMMUNITY_URL,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
message_kwargs: dict = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': message,
|
||||
'parse_mode': ParseMode.HTML,
|
||||
'reply_markup': keyboard,
|
||||
'disable_web_page_preview': True,
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info('Стартовое уведомление отправлено в чат %s', self.chat_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки стартового уведомления: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
async def send_bot_startup_notification(bot: Bot) -> bool:
|
||||
"""
|
||||
Удобная функция для отправки стартового уведомления.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота aiogram
|
||||
|
||||
Returns:
|
||||
bool: True если уведомление отправлено успешно
|
||||
"""
|
||||
service = StartupNotificationService(bot)
|
||||
return await service.send_startup_notification()
|
||||
|
||||
|
||||
def _get_error_recommendations(error_message: str) -> str | None:
|
||||
"""
|
||||
Возвращает рекомендации по исправлению ошибки на основе текста ошибки.
|
||||
|
||||
Args:
|
||||
error_message: Текст ошибки
|
||||
|
||||
Returns:
|
||||
Рекомендации в формате HTML blockquote или None
|
||||
"""
|
||||
error_lower = error_message.lower()
|
||||
|
||||
# Ошибки вебхука
|
||||
if any(keyword in error_lower for keyword in WEBHOOK_ERROR_KEYWORDS):
|
||||
tips = [
|
||||
'• Проверьте WEBHOOK_HOST в .env',
|
||||
'• Убедитесь что домен доступен извне',
|
||||
'• Проверьте SSL сертификат (должен быть валидный)',
|
||||
'• Проверьте reverse proxy (nginx/caddy)',
|
||||
'• Проверьте сеть Docker (docker network)',
|
||||
'• Попробуйте: docker compose restart',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
# Ошибки подключения к БД
|
||||
if any(keyword in error_lower for keyword in DATABASE_ERROR_KEYWORDS):
|
||||
tips = [
|
||||
'• Проверьте что PostgreSQL запущен',
|
||||
'• Проверьте DATABASE_URL в .env',
|
||||
'• Проверьте сеть Docker между контейнерами',
|
||||
'• Попробуйте: docker compose restart db',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
# Ошибки Redis
|
||||
if REDIS_ERROR_KEYWORD in error_lower:
|
||||
tips = [
|
||||
'• Проверьте что Redis запущен',
|
||||
'• Проверьте REDIS_URL в .env',
|
||||
'• Попробуйте: docker compose restart redis',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
# Ошибки Remnawave API
|
||||
if any(keyword in error_lower for keyword in REMNAWAVE_ERROR_KEYWORDS):
|
||||
tips = [
|
||||
'• Проверьте REMNAWAVE_API_URL в .env',
|
||||
'• Проверьте REMNAWAVE_API_KEY',
|
||||
'• Убедитесь что панель Remnawave доступна',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
# Ошибки токена бота
|
||||
if any(keyword in error_lower for keyword in AUTH_ERROR_KEYWORDS):
|
||||
tips = [
|
||||
'• Проверьте BOT_TOKEN в .env',
|
||||
'• Убедитесь что токен актуален (@BotFather)',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
# Ошибки inline-кнопок с URL (WebApp, кастомные протоколы)
|
||||
if any(keyword in error_lower for keyword in INLINE_BUTTON_URL_ERROR_KEYWORDS):
|
||||
tips = [
|
||||
'• Проверьте MINIAPP_CUSTOM_URL в .env',
|
||||
'• Проверьте HAPP_CRYPTOLINK_REDIRECT_TEMPLATE',
|
||||
'• Telegram не поддерживает кастомные схемы (happ://, v2ray://, ss://, и т.д.) в inline-кнопках',
|
||||
'• Используйте HTTPS редирект для диплинков',
|
||||
]
|
||||
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str) -> bool:
|
||||
"""
|
||||
Отправляет уведомление о падении бота с лог-файлом.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота aiogram
|
||||
error: Исключение, вызвавшее падение
|
||||
traceback_str: Строка с полным traceback
|
||||
|
||||
Returns:
|
||||
bool: True если уведомление отправлено успешно
|
||||
"""
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
logger.debug('Уведомление о падении отключено или chat_id не задан')
|
||||
return False
|
||||
|
||||
try:
|
||||
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
|
||||
error_type = type(error).__name__
|
||||
error_message = str(error)[:CRASH_ERROR_MESSAGE_MAX_LENGTH]
|
||||
separator = '=' * REPORT_SEPARATOR_WIDTH
|
||||
|
||||
# Формируем содержимое лог-файла
|
||||
log_content = (
|
||||
f'CRASH REPORT\n'
|
||||
f'{separator}\n\n'
|
||||
f'Timestamp: {timestamp}\n'
|
||||
f'Error Type: {error_type}\n'
|
||||
f'Error Message: {error_message}\n\n'
|
||||
f'{separator}\n'
|
||||
f'TRACEBACK\n'
|
||||
f'{separator}\n\n'
|
||||
f'{traceback_str}\n'
|
||||
)
|
||||
|
||||
# Создаем файл для отправки
|
||||
file_name = f'crash_report_{datetime.utcnow().strftime(DATETIME_FORMAT_FILENAME)}.txt'
|
||||
file = BufferedInputFile(
|
||||
file=log_content.encode('utf-8'),
|
||||
filename=file_name,
|
||||
)
|
||||
|
||||
# Текст сообщения
|
||||
message_text = (
|
||||
f'<b>Remnawave Bedolaga Bot</b>\n\n'
|
||||
f'❌ Бот упал с ошибкой\n\n'
|
||||
f'<b>Тип:</b> <code>{error_type}</code>\n'
|
||||
f'<b>Сообщение:</b> <code>{error_message[:CRASH_ERROR_PREVIEW_LENGTH]}</code>\n'
|
||||
)
|
||||
|
||||
# Добавляем рекомендации если есть
|
||||
recommendations = _get_error_recommendations(error_message)
|
||||
if recommendations:
|
||||
message_text += f'\n{recommendations}\n'
|
||||
|
||||
message_text += f'\n<i>{timestamp}</i>'
|
||||
|
||||
# Кнопка для связи с разработчиком
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='💬 Сообщить разработчику',
|
||||
url=DEVELOPER_CONTACT_URL,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
message_kwargs: dict = {
|
||||
'chat_id': chat_id,
|
||||
'document': file,
|
||||
'caption': message_text,
|
||||
'parse_mode': ParseMode.HTML,
|
||||
'reply_markup': keyboard,
|
||||
}
|
||||
|
||||
if topic_id:
|
||||
message_kwargs['message_thread_id'] = topic_id
|
||||
|
||||
await bot.send_document(**message_kwargs)
|
||||
logger.info('Уведомление о падении отправлено в чат %s', chat_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о падении: %s', e)
|
||||
return False
|
||||
@@ -465,6 +465,12 @@ class BotConfigurationService:
|
||||
ChoiceOption('ERROR', '❌ Error'),
|
||||
ChoiceOption('CRITICAL', '🔥 Critical'),
|
||||
],
|
||||
'TRIAL_DISABLED_FOR': [
|
||||
ChoiceOption('none', '✅ Включён для всех'),
|
||||
ChoiceOption('email', '📧 Отключён для Email'),
|
||||
ChoiceOption('telegram', '📱 Отключён для Telegram'),
|
||||
ChoiceOption('all', '🚫 Отключён для всех'),
|
||||
],
|
||||
}
|
||||
|
||||
SETTING_HINTS: dict[str, dict[str, str]] = {
|
||||
|
||||
@@ -198,7 +198,7 @@ class TributeService:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'⌘ Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
|
||||
logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: dict[str, Any]):
|
||||
try:
|
||||
@@ -466,7 +466,7 @@ class TributeService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'⌘ Ошибка принудительной обработки: {e}', exc_info=True)
|
||||
logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None:
|
||||
|
||||
+121
-12
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, types
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -30,9 +30,11 @@ from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
BroadcastHistory,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
@@ -345,6 +347,72 @@ class UserService:
|
||||
'total_count': 0,
|
||||
}
|
||||
|
||||
async def get_potential_customers(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
min_balance_kopeks: int,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Возвращает пользователей без активной подписки с достаточным балансом."""
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Фильтры: нет активной подписки И баланс >= порога
|
||||
base_filters = [
|
||||
User.balance_kopeks >= min_balance_kopeks,
|
||||
]
|
||||
|
||||
# Основной запрос с LEFT JOIN для поддержки пользователей без подписки
|
||||
query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.where(
|
||||
*base_filters,
|
||||
or_(
|
||||
User.subscription == None,
|
||||
~Subscription.status.in_(['active', 'trial']),
|
||||
),
|
||||
)
|
||||
.order_by(User.balance_kopeks.desc(), User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Запрос для подсчета общего количества
|
||||
count_query = (
|
||||
select(func.count(User.id))
|
||||
.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.where(
|
||||
*base_filters,
|
||||
or_(
|
||||
User.subscription == None,
|
||||
~Subscription.status.in_(['active', 'trial']),
|
||||
),
|
||||
)
|
||||
)
|
||||
total_count = (await db.execute(count_query)).scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit if total_count else 0
|
||||
|
||||
return {
|
||||
'users': users,
|
||||
'current_page': page,
|
||||
'total_pages': total_pages,
|
||||
'total_count': total_count,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка получения потенциальных клиентов: {e}')
|
||||
return {
|
||||
'users': [],
|
||||
'current_page': 1,
|
||||
'total_pages': 1,
|
||||
'total_count': 0,
|
||||
}
|
||||
|
||||
async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]:
|
||||
try:
|
||||
return await get_users_spending_stats(db, user_ids)
|
||||
@@ -891,6 +959,58 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления Freekassa платежей: {e}')
|
||||
|
||||
# Удаляем Wata платежи (до транзакций, т.к. wata_payments.transaction_id -> transactions.id)
|
||||
try:
|
||||
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
|
||||
wata_payments = wata_payments_result.scalars().all()
|
||||
|
||||
if wata_payments:
|
||||
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
|
||||
await db.execute(
|
||||
update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
|
||||
|
||||
# Удаляем CloudPayments платежи
|
||||
try:
|
||||
cloudpayments_result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)
|
||||
)
|
||||
cloudpayments_payments = cloudpayments_result.scalars().all()
|
||||
|
||||
if cloudpayments_payments:
|
||||
logger.info(f'🔄 Удаляем {len(cloudpayments_payments)} CloudPayments платежей')
|
||||
await db.execute(
|
||||
update(CloudPaymentsPayment)
|
||||
.where(CloudPaymentsPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления CloudPayments платежей: {e}')
|
||||
|
||||
# Удаляем KassaAi платежи
|
||||
try:
|
||||
kassa_ai_result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
|
||||
kassa_ai_payments = kassa_ai_result.scalars().all()
|
||||
|
||||
if kassa_ai_payments:
|
||||
logger.info(f'🔄 Удаляем {len(kassa_ai_payments)} KassaAi платежей')
|
||||
await db.execute(
|
||||
update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления KassaAi платежей: {e}')
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id))
|
||||
transactions = transactions_result.scalars().all()
|
||||
@@ -990,17 +1110,6 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления подписки: {e}')
|
||||
|
||||
try:
|
||||
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
|
||||
wata_payments = wata_payments_result.scalars().all()
|
||||
|
||||
if wata_payments:
|
||||
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
|
||||
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
|
||||
|
||||
try:
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
@@ -51,6 +51,13 @@ class VersionInfo:
|
||||
|
||||
return description
|
||||
|
||||
@property
|
||||
def full_description(self) -> str:
|
||||
"""Full release body text, stripped of leading/trailing whitespace."""
|
||||
if not self.body:
|
||||
return ''
|
||||
return self.body.strip()
|
||||
|
||||
|
||||
class VersionService:
|
||||
def __init__(self, bot=None):
|
||||
|
||||
+8
-4
@@ -118,6 +118,9 @@ class AdminStates(StatesGroup):
|
||||
adding_virtual_participant_name = State()
|
||||
adding_virtual_participant_count = State()
|
||||
editing_virtual_participant_count = State()
|
||||
# Массовое создание виртуальных участников (массовка)
|
||||
adding_mass_virtual_count = State() # Сколько призраков создать
|
||||
adding_mass_virtual_referrals = State() # По сколько рефералов у каждого
|
||||
editing_daily_contest_field = State()
|
||||
editing_daily_contest_value = State()
|
||||
|
||||
@@ -132,6 +135,10 @@ class AdminStates(StatesGroup):
|
||||
# Тестовое начисление реферального дохода
|
||||
test_referral_earning_input = State()
|
||||
|
||||
# Диагностика рефералов
|
||||
referral_diagnostics_period = State()
|
||||
waiting_for_log_file = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
editing_public_offer = State()
|
||||
@@ -173,12 +180,9 @@ class AdminStates(StatesGroup):
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
viewing_user_from_traffic_list = State()
|
||||
viewing_user_from_last_activity_list = State()
|
||||
viewing_user_from_spending_list = State()
|
||||
viewing_user_from_purchases_list = State()
|
||||
viewing_user_from_campaign_list = State()
|
||||
viewing_user_from_ready_to_renew_list = State()
|
||||
viewing_user_from_potential_customers_list = State()
|
||||
|
||||
# Состояния для управления тарифами
|
||||
creating_tariff_name = State()
|
||||
|
||||
+11
-9
@@ -89,15 +89,16 @@ def error_handler(func: Callable) -> Callable:
|
||||
await event.answer()
|
||||
except TelegramBadRequest as answer_error:
|
||||
if 'query is too old' not in str(answer_error).lower():
|
||||
logger.error(f'Ошибка при ответе на callback: {answer_error}')
|
||||
logger.error(f'Ошибка при ответе на callback в {func.__name__}: {answer_error}')
|
||||
return None
|
||||
|
||||
logger.error(f'Telegram API error в {func.__name__}: {e}')
|
||||
await _send_error_message(args, kwargs, e)
|
||||
# Уведомление отправляется в _send_error_message
|
||||
await _send_error_message(args, kwargs, e, func.__name__)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в {func.__name__}: {e}', exc_info=True)
|
||||
await _send_error_message(args, kwargs, e)
|
||||
await _send_error_message(args, kwargs, e, func.__name__)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -109,11 +110,12 @@ def _extract_event(args) -> types.TelegramObject:
|
||||
return None
|
||||
|
||||
|
||||
async def _send_error_message(args, kwargs, original_error):
|
||||
try:
|
||||
event = _extract_event(args)
|
||||
db_user = kwargs.get('db_user')
|
||||
async def _send_error_message(args, kwargs, original_error, func_name: str = 'unknown'):
|
||||
event = _extract_event(args)
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
# Отправляем сообщение пользователю
|
||||
try:
|
||||
if not event:
|
||||
return
|
||||
|
||||
@@ -128,9 +130,9 @@ async def _send_error_message(args, kwargs, original_error):
|
||||
if 'query is too old' in str(e).lower():
|
||||
logger.warning('Не удалось отправить сообщение об ошибке - callback query устарел')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке сообщения об ошибке: {e}')
|
||||
logger.warning(f'Ошибка при отправке сообщения об ошибке: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
|
||||
logger.warning(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Converts GitHub-flavored Markdown to Telegram-compatible HTML.
|
||||
|
||||
Telegram supports a limited subset of HTML tags:
|
||||
<b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>, <tg-spoiler>.
|
||||
|
||||
This module strips everything else and maps common Markdown constructs
|
||||
to the supported tags.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# HTML tags that Telegram Bot API supports (case-insensitive tag names)
|
||||
_ALLOWED_TAGS: frozenset[str] = frozenset(
|
||||
{
|
||||
'b',
|
||||
'strong',
|
||||
'i',
|
||||
'em',
|
||||
'u',
|
||||
'ins',
|
||||
's',
|
||||
'strike',
|
||||
'del',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'blockquote',
|
||||
'tg-spoiler',
|
||||
'tg-emoji',
|
||||
}
|
||||
)
|
||||
|
||||
# Regex to match any HTML tag (opening, closing, or self-closing)
|
||||
_HTML_TAG_RE: re.Pattern[str] = re.compile(r'<(/?)(\w[\w-]*)((?:\s+[^>]*)?)(/?)>', re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_unsupported_html(text: str) -> str:
|
||||
"""Remove HTML tags that Telegram does not support, keeping only allowed ones."""
|
||||
|
||||
def _replace_tag(match: re.Match[str]) -> str:
|
||||
tag_name = match.group(2).lower()
|
||||
if tag_name in _ALLOWED_TAGS:
|
||||
return match.group(0)
|
||||
return ''
|
||||
|
||||
return _HTML_TAG_RE.sub(_replace_tag, text)
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape characters that conflict with Telegram HTML parsing.
|
||||
|
||||
Only escapes `&`, `<`, `>` that are NOT already part of allowed HTML tags.
|
||||
We run this BEFORE markdown conversion so markdown symbols are still intact.
|
||||
"""
|
||||
# Escape ampersands that are not already HTML entities
|
||||
text = re.sub(r'&(?!amp;|lt;|gt;|quot;|#\d+;)', '&', text)
|
||||
return text
|
||||
|
||||
|
||||
def github_markdown_to_telegram_html(text: str) -> str:
|
||||
"""Convert GitHub-flavored Markdown to Telegram HTML.
|
||||
|
||||
Handles:
|
||||
- ``## Header`` -> ``<b>Header</b>``
|
||||
- ``**bold**`` / ``__bold__`` -> ``<b>bold</b>``
|
||||
- ``*italic*`` / ``_italic_`` -> ``<i>italic</i>``
|
||||
- `` `code` `` -> ``<code>code</code>``
|
||||
- ``- item`` / ``* item`` -> ``bullet item``
|
||||
- ``[text](url)`` -> ``<a href="url">text</a>``
|
||||
- Strips unsupported HTML tags
|
||||
"""
|
||||
if not text:
|
||||
return ''
|
||||
|
||||
# Escape HTML-sensitive chars first (but preserve existing tags for later stripping)
|
||||
# We do a targeted escape: only bare < > that are NOT part of tags
|
||||
result = text
|
||||
|
||||
# --- Code blocks (``` ... ```) -- protect from further processing ---
|
||||
code_blocks: list[str] = []
|
||||
|
||||
def _save_code_block(match: re.Match[str]) -> str:
|
||||
lang = match.group(1) or ''
|
||||
code = match.group(2)
|
||||
# Escape HTML inside code
|
||||
code = code.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
placeholder = f'\x00CODEBLOCK{len(code_blocks)}\x00'
|
||||
if lang:
|
||||
code_blocks.append(f'<pre><code class="language-{lang}">{code}</code></pre>')
|
||||
else:
|
||||
code_blocks.append(f'<pre>{code}</pre>')
|
||||
return placeholder
|
||||
|
||||
result = re.sub(r'```(\w+)?\n(.*?)```', _save_code_block, result, flags=re.DOTALL)
|
||||
|
||||
# --- Inline code (`...`) -- protect from further processing ---
|
||||
inline_codes: list[str] = []
|
||||
|
||||
def _save_inline_code(match: re.Match[str]) -> str:
|
||||
code = match.group(1)
|
||||
code = code.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
placeholder = f'\x00INLINECODE{len(inline_codes)}\x00'
|
||||
inline_codes.append(f'<code>{code}</code>')
|
||||
return placeholder
|
||||
|
||||
result = re.sub(r'`([^`]+)`', _save_inline_code, result)
|
||||
|
||||
# --- Escape remaining bare HTML entities ---
|
||||
result = _escape_html(result)
|
||||
|
||||
# --- Headers: ## Header -> <b>Header</b> ---
|
||||
result = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', result, flags=re.MULTILINE)
|
||||
|
||||
# --- Bold: **text** or __text__ -> <b>text</b> ---
|
||||
result = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', result)
|
||||
result = re.sub(r'__(.+?)__', r'<b>\1</b>', result)
|
||||
|
||||
# --- Italic: *text* or _text_ -> <i>text</i> ---
|
||||
# Negative lookbehind/lookahead to avoid matching inside words with underscores
|
||||
result = re.sub(r'(?<!\w)\*([^*]+?)\*(?!\w)', r'<i>\1</i>', result)
|
||||
result = re.sub(r'(?<!\w)_([^_]+?)_(?!\w)', r'<i>\1</i>', result)
|
||||
|
||||
# --- Strikethrough: ~~text~~ -> <s>text</s> ---
|
||||
result = re.sub(r'~~(.+?)~~', r'<s>\1</s>', result)
|
||||
|
||||
# --- Links: [text](url) -> <a href="url">text</a> ---
|
||||
result = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', result)
|
||||
|
||||
# --- Unordered lists: - item or * item -> bullet ---
|
||||
_BULLET = '\u2022'
|
||||
result = re.sub(r'^[\s]*[-*]\s+', f' {_BULLET} ', result, flags=re.MULTILINE)
|
||||
|
||||
# --- Horizontal rules: --- or *** or ___ ---
|
||||
result = re.sub(r'^[-*_]{3,}\s*$', '', result, flags=re.MULTILINE)
|
||||
|
||||
# --- Images:  -> just alt text ---
|
||||
result = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', result)
|
||||
|
||||
# --- Strip unsupported HTML tags ---
|
||||
result = _strip_unsupported_html(result)
|
||||
|
||||
# --- Restore code blocks ---
|
||||
for i, block in enumerate(code_blocks):
|
||||
result = result.replace(f'\x00CODEBLOCK{i}\x00', block)
|
||||
|
||||
for i, code in enumerate(inline_codes):
|
||||
result = result.replace(f'\x00INLINECODE{i}\x00', code)
|
||||
|
||||
# --- Clean up excessive blank lines (max 2 consecutive) ---
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _close_open_tags(html: str) -> str:
|
||||
"""Find unclosed HTML tags and append closing tags in reverse order."""
|
||||
open_tags: list[str] = []
|
||||
for match in _HTML_TAG_RE.finditer(html):
|
||||
is_closing = match.group(1) == '/'
|
||||
is_self_closing = match.group(4) == '/'
|
||||
tag_name = match.group(2).lower()
|
||||
if is_self_closing:
|
||||
continue
|
||||
if is_closing:
|
||||
if open_tags and open_tags[-1] == tag_name:
|
||||
open_tags.pop()
|
||||
else:
|
||||
open_tags.append(tag_name)
|
||||
# Close remaining open tags in reverse order
|
||||
for tag in reversed(open_tags):
|
||||
html += f'</{tag}>'
|
||||
return html
|
||||
|
||||
|
||||
def truncate_for_blockquote(
|
||||
description_html: str,
|
||||
*,
|
||||
message_prefix: str,
|
||||
message_suffix: str,
|
||||
max_message_length: int = 4096,
|
||||
ellipsis: str = '...',
|
||||
) -> str:
|
||||
"""Truncate description HTML to fit within Telegram message limit inside a blockquote.
|
||||
|
||||
Calculates available space by subtracting prefix/suffix lengths and blockquote
|
||||
tag overhead from the total message limit.
|
||||
|
||||
Args:
|
||||
description_html: The already-converted HTML description.
|
||||
message_prefix: Everything before the blockquote in the message.
|
||||
message_suffix: Everything after the blockquote in the message.
|
||||
max_message_length: Telegram message character limit (default 4096).
|
||||
ellipsis: String to append when truncating.
|
||||
|
||||
Returns:
|
||||
The (possibly truncated) description HTML ready to be placed inside
|
||||
``<blockquote expandable>...</blockquote>``.
|
||||
"""
|
||||
blockquote_open = '<blockquote expandable>'
|
||||
blockquote_close = '</blockquote>'
|
||||
overhead = len(blockquote_open) + len(blockquote_close)
|
||||
|
||||
available = max_message_length - len(message_prefix) - len(message_suffix) - overhead
|
||||
# Leave a small safety margin for any off-by-one with Telegram entity counting
|
||||
available -= 20
|
||||
|
||||
if available <= 0:
|
||||
return ellipsis
|
||||
|
||||
if len(description_html) <= available:
|
||||
return description_html
|
||||
|
||||
# Reserve space for ellipsis, then iteratively truncate until
|
||||
# the result (with closing tags) fits within the budget.
|
||||
budget = available - len(ellipsis)
|
||||
truncated = description_html[:budget]
|
||||
|
||||
# If we broke an HTML tag, backtrack to before it
|
||||
last_open = truncated.rfind('<')
|
||||
last_close = truncated.rfind('>')
|
||||
if last_open > last_close:
|
||||
truncated = truncated[:last_open]
|
||||
|
||||
# Close any unclosed HTML tags to avoid Telegram parse errors
|
||||
closed = _close_open_tags(truncated)
|
||||
|
||||
# If closing tags pushed us over budget, trim more text
|
||||
while len(closed) + len(ellipsis) > available and len(truncated) > 0:
|
||||
truncated = truncated[:-20] if len(truncated) > 20 else ''
|
||||
last_open = truncated.rfind('<')
|
||||
last_close = truncated.rfind('>')
|
||||
if last_open > last_close:
|
||||
truncated = truncated[:last_open]
|
||||
closed = _close_open_tags(truncated)
|
||||
|
||||
return closed.rstrip() + ellipsis
|
||||
@@ -3047,6 +3047,9 @@ def _is_trial_available_for_user(user: User) -> bool:
|
||||
if settings.TRIAL_DURATION_DAYS <= 0:
|
||||
return False
|
||||
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
return False
|
||||
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
return False
|
||||
|
||||
@@ -3698,7 +3701,22 @@ async def update_subscription_autopay_endpoint(
|
||||
subscription = _ensure_paid_subscription(user)
|
||||
_validate_subscription_id(payload.subscription_id, subscription)
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
target_enabled = bool(payload.enabled) if payload.enabled is not None else bool(subscription.autopay_enabled)
|
||||
if target_enabled:
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'autopay_not_available_for_daily',
|
||||
'message': 'Autopay is not available for daily subscriptions',
|
||||
},
|
||||
)
|
||||
|
||||
requested_days = payload.days_before
|
||||
normalized_days = _normalize_autopay_days(requested_days)
|
||||
|
||||
+181
-102
@@ -50,7 +50,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
|
||||
if not secret_key:
|
||||
logger.error('%s secret key is not configured', display_name)
|
||||
logger.warning('%s secret key is not configured', display_name)
|
||||
return False
|
||||
|
||||
signature = _extract_header(
|
||||
@@ -95,7 +95,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
|
||||
if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error('Неверная подпись %s webhook', display_name)
|
||||
logger.warning('Неверная подпись %s webhook', display_name)
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
@@ -107,7 +107,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
|
||||
if scheme_lower in {'bearer', 'token'}:
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
logger.error('Неверный %s токен %s webhook', scheme, display_name)
|
||||
logger.warning('Неверный %s токен %s webhook', scheme, display_name)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
@@ -124,7 +124,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error('Отсутствует подпись %s webhook', display_name)
|
||||
logger.warning('Отсутствует подпись %s webhook', display_name)
|
||||
return False
|
||||
|
||||
|
||||
@@ -217,14 +217,23 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
result = await tribute_service.process_webhook(payload)
|
||||
if result:
|
||||
return JSONResponse({'status': 'ok', 'result': result})
|
||||
try:
|
||||
result = await tribute_service.process_webhook(payload)
|
||||
if result:
|
||||
return JSONResponse({'status': 'ok', 'result': result})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
error = ValueError('Tribute webhook processing returned empty result')
|
||||
logger.error('Tribute webhook processing failed: %s', error)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Tribute webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -256,18 +265,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_mulenpay_callback',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_mulenpay_callback',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
logger.error('MulenPay webhook processing failed: %s', payload)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('MulenPay webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -311,18 +328,29 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_cryptobot_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_cryptobot_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
logger.error(
|
||||
'CryptoBot webhook processing failed: invoice_id=%s',
|
||||
payload.get('payload', {}).get('invoice_id'),
|
||||
)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('CryptoBot webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -416,18 +444,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
}:
|
||||
return JSONResponse({'status': 'ok', 'ignored': event_type})
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
'process_yookassa_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
'process_yookassa_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
payment_id = webhook_data.get('object', {}).get('id', 'unknown')
|
||||
logger.error('YooKassa webhook processing failed: payment_id=%s', payment_id)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('YooKassa webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'processing_failed'},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -478,18 +515,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_wata_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_wata_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
order_id = payload.get('orderId') or payload.get('order_id') or 'unknown'
|
||||
logger.error('Wata webhook processing failed: order_id=%s, payload=%s', order_id, payload)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Wata webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -533,18 +579,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_heleket_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_heleket_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
uuid_val = payload.get('uuid', 'unknown')
|
||||
logger.error('Heleket webhook processing failed: uuid=%s', uuid_val)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Heleket webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -595,18 +650,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
parsed_payload,
|
||||
'process_pal24_callback',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
parsed_payload,
|
||||
'process_pal24_callback',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
bill_id = parsed_payload.get('bill_id', 'unknown')
|
||||
logger.error('Pal24 webhook processing failed: bill_id=%s', bill_id)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Pal24 webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -640,18 +704,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_platega_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_platega_webhook',
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'})
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
transaction_id = payload.get('transactionId', 'unknown')
|
||||
logger.error('Platega webhook processing failed: transactionId=%s', transaction_id)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Platega webhook processing error: %s', e)
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'not_processed'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -902,8 +975,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
# Получаем данные формы
|
||||
try:
|
||||
form_data = await request.form()
|
||||
except Exception:
|
||||
logger.error('Freekassa webhook: не удалось прочитать данные формы')
|
||||
except Exception as form_error:
|
||||
logger.error('Freekassa webhook: не удалось прочитать данные формы: %s', form_error)
|
||||
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Извлекаем параметры
|
||||
@@ -945,17 +1018,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
cur_id=cur_id_int,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
if success:
|
||||
return Response('YES', status_code=status.HTTP_200_OK)
|
||||
|
||||
logger.error('Freekassa webhook processing failed: order_id=%s, intid=%s', order_id, intid)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
logger.exception('Freekassa webhook processing error: %s', e)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
if success:
|
||||
return Response('YES', status_code=status.HTTP_200_OK)
|
||||
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# KassaAI webhook
|
||||
@@ -976,8 +1052,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
# Получаем данные формы
|
||||
try:
|
||||
form_data = await request.form()
|
||||
except Exception:
|
||||
logger.error('KassaAI webhook: не удалось прочитать данные формы')
|
||||
except Exception as form_error:
|
||||
logger.error('KassaAI webhook: не удалось прочитать данные формы: %s', form_error)
|
||||
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Извлекаем параметры (те же что и у Freekassa)
|
||||
@@ -1017,17 +1093,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
intid=intid,
|
||||
cur_id=cur_id_int,
|
||||
)
|
||||
if success:
|
||||
return Response('YES', status_code=status.HTTP_200_OK)
|
||||
|
||||
logger.error('KassaAI webhook processing failed: order_id=%s, intid=%s', order_id, intid)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
logger.exception('KassaAI webhook processing error: %s', e)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
if success:
|
||||
return Response('YES', status_code=status.HTTP_200_OK)
|
||||
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.cabinet.routes import router as cabinet_router
|
||||
from app.config import settings
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.webapi.app import create_web_api_app
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
@@ -144,6 +145,14 @@ def create_unified_app(
|
||||
else:
|
||||
telegram_processor = None
|
||||
|
||||
@app.on_event('startup')
|
||||
async def start_disposable_email_service() -> None: # pragma: no cover - event hook
|
||||
await disposable_email_service.start()
|
||||
|
||||
@app.on_event('shutdown')
|
||||
async def stop_disposable_email_service() -> None: # pragma: no cover - event hook
|
||||
await disposable_email_service.stop()
|
||||
|
||||
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
|
||||
|
||||
unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user