Compare commits

...

125 Commits

Author SHA1 Message Date
Egor c0953eecf3 Update docker-registry.yml 2025-09-08 09:52:34 +03:00
Egor 7f70826023 Update docker-hub.yml 2025-09-08 09:52:06 +03:00
Egor a7aaa6fc88 Update Dockerfile 2025-09-08 09:51:29 +03:00
Egor a72b3bf705 Update version_service.py 2025-09-08 09:47:52 +03:00
Egor 71656df0bd Update version_service.py 2025-09-08 09:08:26 +03:00
Egor 7d5cff0aa9 Update version_service.py 2025-09-08 09:06:17 +03:00
Egor 4e27396a09 Update version_service.py 2025-09-08 08:51:34 +03:00
Egor 52a72a11b0 Update docker-registry.yml 2025-09-08 08:18:55 +03:00
Egor c17886489b Update Dockerfile 2025-09-08 07:46:58 +03:00
Egor ade2f106ba Update Dockerfile 2025-09-08 07:15:42 +03:00
Egor a096b2bc1a Merge pull request #43 from Fr1ngg/dev
Проверка обновлений
2025-09-08 06:45:52 +03:00
Egor de65390680 Update .env.example 2025-09-08 06:44:01 +03:00
Egor bd5f92c121 Update Dockerfile 2025-09-08 06:36:57 +03:00
Egor ad53a6bc09 Update requirements.txt 2025-09-08 06:36:20 +03:00
Egor f3b2c8056e Update Dockerfile 2025-09-08 06:36:05 +03:00
Egor 1e90903d54 Update bot.py 2025-09-08 06:22:21 +03:00
Egor 8537875537 Update admin.py 2025-09-08 06:19:31 +03:00
Egor a988e562a0 Update main.py 2025-09-08 06:17:43 +03:00
Egor d014a54181 Update config.py 2025-09-08 06:13:08 +03:00
Egor 4e5d57bf92 Update admin_notification_service.py 2025-09-08 06:11:53 +03:00
Egor 6f8ebbb9f0 Create updates.py 2025-09-08 06:10:34 +03:00
Egor 131ac32596 Create version_service.py 2025-09-08 06:09:01 +03:00
Egor 56017d023c Update docker-registry.yml 2025-09-08 05:57:46 +03:00
Egor 3afdc6cd31 Update docker-hub.yml 2025-09-08 05:57:22 +03:00
Egor 52a89eac77 Merge pull request #42 from yazhog/main
Фикс косяков с начислениями при первом пополнении
2025-09-08 00:10:30 +03:00
yazhog 0cdd311832 Merge pull request #6 from yazhog/codex/add-button-to-top-up-balance
feat: add top up prompt on insufficient balance
2025-09-07 22:17:25 +03:00
yazhog 1d5b52ef5b Переброс при недостаточно балансе на пополнение баланса 2025-09-07 22:16:49 +03:00
yazhog 3f37be4509 feat: add top up prompt on insufficient balance 2025-09-07 22:03:27 +03:00
yazhog 1636af1585 Merge pull request #5 from yazhog/codex/update-referral-bonus-calculation-logic
feat: calculate referral inviter bonus as max flat or percent
2025-09-07 21:07:33 +03:00
yazhog 127409955c feat: calculate referral inviter bonus as max flat or percent 2025-09-07 21:01:40 +03:00
Egor 423ce5e41b Update docker-compose.yml 2025-09-07 19:43:06 +03:00
Egor 770b7e8358 Update docker-compose.yml 2025-09-07 19:40:53 +03:00
Egor 0daf08ceed Update docker-compose.yml 2025-09-07 19:33:22 +03:00
Egor 1cda2259c6 Update config.py 2025-09-07 19:30:46 +03:00
Egor 5879080f33 Update .env.example 2025-09-07 19:26:54 +03:00
Egor cd3ad8bff9 Merge pull request #41 from yazhog/main
Убрал копейки + QR код для рефералки
2025-09-07 18:26:46 +03:00
yazhog 033a748069 Эмодзи на кнопках потерялись 2025-09-07 14:08:53 +03:00
yazhog de890dbd7f Merge pull request #4 from yazhog/codex/-qr
Add referral QR code generation
2025-09-07 13:53:43 +03:00
yazhog 7c5ae09a6e Refactor QR code display to edit message 2025-09-07 13:48:22 +03:00
yazhog e587e3b087 Fix back navigation after QR code 2025-09-07 13:35:19 +03:00
yazhog d698b3cfe7 Show referral QR code via screen transition 2025-09-07 13:30:41 +03:00
yazhog a01680fa2d Add referral QR code generation 2025-09-07 13:23:49 +03:00
yazhog 531fc0fb22 Merge pull request #3 from yazhog/codex/remove-decimal-points-in-ruble-display
feat: display ruble prices without kopecks
2025-09-07 13:19:36 +03:00
yazhog bb8e513598 feat: display ruble prices without kopecks 2025-09-07 13:13:03 +03:00
Egor f56679cd9f Update validators.py 2025-09-07 11:15:13 +03:00
Egor 9885e050a1 Update README.md 2025-09-07 10:22:21 +03:00
Egor 68be5cbe4b Merge pull request #39 from Fr1ngg/dev
Антихлебная валидация HTML разметки при создании сообщения в меню
2025-09-07 08:45:21 +03:00
Egor 3315c83e0b Update user_messages.py 2025-09-07 08:38:25 +03:00
Egor ca9b6f4063 Update user_messages.py 2025-09-07 08:34:46 +03:00
Egor cc490b616f Update user_messages.py 2025-09-07 08:28:02 +03:00
Egor 988f9eb4eb Update validators.py 2025-09-07 08:21:14 +03:00
Egor c41eeb2c8b Update user_messages.py 2025-09-07 08:12:39 +03:00
Egor 881963ca7b Update validators.py 2025-09-07 08:11:55 +03:00
Egor db6733083c Update validators.py 2025-09-07 08:07:45 +03:00
Egor 77851b090a Merge pull request #38 from Fr1ngg/dev
Новый модуль, с настройками в админке. Выводит рандомные сообщения юзера, html поддержка
2025-09-07 06:36:30 +03:00
Egor e0ada505aa Update admin.py 2025-09-07 06:25:53 +03:00
Egor 3179f33b67 Update menu.py 2025-09-07 06:19:46 +03:00
Egor 9bbd72b65f Update menu.py 2025-09-07 06:13:18 +03:00
Egor 68ace1358b Update admin.py 2025-09-07 06:07:48 +03:00
Egor f7934fd0fe Update user_messages.py 2025-09-07 05:59:44 +03:00
Egor 15cbbc04ce Update user_messages.py 2025-09-07 05:54:15 +03:00
Egor d4260106cb Update user_messages.py 2025-09-07 05:49:31 +03:00
Egor 0a46bd18db Update user_messages.py 2025-09-07 05:43:36 +03:00
Egor 0ccbad4077 Update bot.py 2025-09-07 05:40:15 +03:00
Egor 013ce09846 Update bot.py 2025-09-07 05:37:10 +03:00
Egor 736ec95572 Update start.py 2025-09-07 05:32:19 +03:00
Egor bddf36261d Create user_messages.py 2025-09-07 05:24:30 +03:00
Egor cbd74049dd Update admin.py 2025-09-07 05:22:34 +03:00
Egor 84f3db4548 Create user_message.py 2025-09-07 05:21:38 +03:00
Egor c07ccf0daf Update models.py 2025-09-07 05:20:29 +03:00
Egor 3ff4906214 Merge pull request #37 from Fr1ngg/dev
Dev
2025-09-07 04:41:57 +03:00
Egor 1bc5133c95 Update remnawave_api.py 2025-09-07 04:37:34 +03:00
Egor dcbad389b3 Update servers.py 2025-09-07 04:36:34 +03:00
Egor dfea057a25 Update maintenance.py 2025-09-07 04:35:59 +03:00
Egor 3a6bf029d1 Update admin_notification_service.py 2025-09-07 04:35:19 +03:00
Egor 667041558e Update maintenance_service.py 2025-09-07 04:34:58 +03:00
Egor 01e51f2dfd Update remnawave_service.py 2025-09-07 04:34:04 +03:00
Egor 483971d12f Update remnawave_service.py 2025-09-07 04:28:40 +03:00
Egor fad93dab53 Update maintenance.py 2025-09-07 04:11:36 +03:00
Egor ff13b7685b Update admin_notification_service.py 2025-09-07 04:10:54 +03:00
Egor 4281701957 Update remnawave_service.py 2025-09-07 04:09:29 +03:00
Egor a0ff5dbcd4 Update admin.py 2025-09-07 03:59:50 +03:00
Egor ed9ac80e68 Update maintenance.py 2025-09-07 03:57:25 +03:00
Egor e9662b9b42 Update remnawave_service.py 2025-09-07 03:55:34 +03:00
Egor ba3fc35a14 Update admin_notification_service.py 2025-09-07 03:50:51 +03:00
Egor 18471f0162 Update maintenance_service.py 2025-09-07 03:48:30 +03:00
Egor 71073fd0e5 Update tribute_service.py 2025-09-07 03:38:02 +03:00
Egor 2e1ee6bb28 Update payment_service.py 2025-09-07 03:33:19 +03:00
Egor 239f14acaf Update subscription.py 2025-09-07 03:24:06 +03:00
Egor e87a62c180 Update admin_notification_service.py 2025-09-07 03:19:29 +03:00
Egor cb275fc4b6 Update admin_notification_service.py 2025-09-07 03:17:01 +03:00
Egor 4042dee884 Update README.md 2025-09-07 03:12:47 +03:00
Egor 2d3dc8e024 Update remnawave.py 2025-09-07 01:04:23 +03:00
Egor 530759fc81 Merge pull request #36 from yazhog/main
Фикс присылания рефкода сообщением
2025-09-07 00:45:50 +03:00
yazhog 0f29850d7f Merge pull request #2 from yazhog/codex/fix-handling-of-unregistered-users
Fix auth middleware referral state handling
2025-09-07 00:18:40 +03:00
yazhog e060c39790 Fix state detection for registration in auth middleware 2025-09-07 00:16:29 +03:00
yazhog 612d7ad529 Allow messages in registration states 2025-09-07 00:08:27 +03:00
Egor a310e88905 Merge pull request #35 from yazhog/main
Проверка подписи API ключем для Tribute
2025-09-06 23:57:46 +03:00
yazhog bda959e8d6 Merge pull request #1 from yazhog/codex/add-payment-signature-validation
feat: validate Tribute webhook signatures
2025-09-06 23:36:33 +03:00
Egor bd9d12c185 Merge pull request #34 from yazhog/main
Исправление начисления партнерки за первый платеж реферала
2025-09-06 23:31:08 +03:00
yazhog adf509dc81 chore: require Tribute signature 2025-09-06 23:11:18 +03:00
yazhog 22f5fc43dd feat(tribute): verify webhook signature 2025-09-06 23:02:57 +03:00
root 49b10b2d4a Исправление начисления партнерки за первый платеж реферала 2025-09-06 22:57:59 +03:00
root 2f218b7567 Сборка вместо latest образа 2025-09-06 22:50:47 +03:00
Egor 1bb0894894 Delete screens directory 2025-09-06 19:40:38 +03:00
Egor 39da15fec2 Merge pull request #33 from Fr1ngg/dev
Переключение типа подписки
2025-09-06 19:18:10 +03:00
Egor 9ff66b9650 Update users.py 2025-09-06 19:11:21 +03:00
Egor c5353b46b1 Update users.py 2025-09-06 19:07:12 +03:00
Egor 25aa214ffa Update users.py 2025-09-06 19:03:55 +03:00
Egor 28279b7ec2 Update README.md 2025-09-06 18:06:17 +03:00
Egor 126170bee7 Update docker-compose.yml 2025-09-06 18:03:23 +03:00
Egor 8ce7935d49 Update Dockerfile 2025-09-06 18:02:05 +03:00
Egor f756c9a7f9 Update user.py 2025-09-06 17:48:53 +03:00
Egor a59905b98f Update referral_service.py 2025-09-06 17:37:40 +03:00
Egor f0d74185a1 Update user.py 2025-09-06 17:34:09 +03:00
Egor 06937e026f Update tribute_service.py 2025-09-06 17:30:45 +03:00
Egor 65acaf7058 Update tribute_service.py 2025-09-06 17:02:29 +03:00
Egor 7fc574daf3 Update README.md 2025-09-06 07:13:53 +03:00
Egor a93eb8d2d1 Update tribute_service.py 2025-09-06 07:08:24 +03:00
Egor 9fbd15f700 Merge pull request #32 from Fr1ngg/dev
Dev
2025-09-06 07:03:27 +03:00
Egor cec96d866c Update tribute_service.py 2025-09-06 07:01:07 +03:00
Egor 80031306a4 Update tribute_service.py 2025-09-06 06:57:18 +03:00
Egor 9783781bfb Merge pull request #31 from Fr1ngg/dev
Dev
2025-09-06 06:42:23 +03:00
Egor aba7302833 Update tribute_service.py 2025-09-06 06:39:45 +03:00
Egor 61ce10c52e Update transaction.py 2025-09-06 06:36:45 +03:00
70 changed files with 3397 additions and 485 deletions
+18 -8
View File
@@ -12,19 +12,26 @@ ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
# ===== DATABASE =====
# Для Docker используйте PostgreSQL:
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
DATABASE_MODE=auto
REDIS_URL=redis://redis:6379/0
# Основной URL (можно оставить пустым для автоматического выбора)
DATABASE_URL=
# Пароли для Docker (PostgreSQL/Redis)
# PostgreSQL настройки (для Docker и кастомных установок)
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
# Redis
REDIS_URL=redis://redis:6379/0
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=
@@ -108,7 +115,6 @@ TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
@@ -216,3 +222,7 @@ WEBHOOK_PATH=/webhook
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
VERSION_CHECK_ENABLED=true
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
VERSION_CHECK_INTERVAL_HOURS=1
+86 -3
View File
@@ -1,28 +1,111 @@
name: BedolagaBot
on:
push:
branches:
- main
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Определяем версию и теги
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.4-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.4-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.3-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Теги: $TAGS"
echo "Push: ${{ github.event_name != 'pull_request' }}"
echo "==========================="
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.version.outputs.tags }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ steps.version.outputs.build_date }}
org.opencontainers.image.revision=${{ steps.version.outputs.short_sha }}
org.opencontainers.image.title=Bedolaga RemnaWave Bot
org.opencontainers.image.description=Telegram bot for RemnaWave VPN service
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Build Summary
if: github.event_name != 'pull_request'
run: |
echo "## 🚀 Docker Hub Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | Docker Hub |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📋 Опубликованные теги:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.version.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🔗 Ссылки:" >> $GITHUB_STEP_SUMMARY
echo "- [Docker Hub Repository](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)" >> $GITHUB_STEP_SUMMARY
+62 -2
View File
@@ -1,20 +1,21 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
- master
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- master
- dev
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -24,14 +25,47 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.4"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Ветка/Тег: $GITHUB_REF"
echo "==========================="
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -45,6 +79,8 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
@@ -54,8 +90,13 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
@@ -67,3 +108,22 @@ jobs:
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
if: github.event_name != 'pull_request'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📋 Доступные теги:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
+19 -14
View File
@@ -1,43 +1,48 @@
FROM python:3.11-slim
ARG VERSION="v2.2.4"
ARG BUILD_DATE
ARG VCS_REF
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
wget \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create non-root user with specific UID/GID for consistency
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
# Copy application code
COPY . .
# Create necessary directories and set permissions
RUN mkdir -p logs data && \
chown -R app:app /app && \
chmod -R 755 /app && \
chmod -R 777 logs
chown -R 1000:1000 ./logs ./data
# Switch to non-root user
USER app
# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV VERSION=${VERSION}
ENV BUILD_DATE=${BUILD_DATE}
ENV VCS_REF=${VCS_REF}
# Expose webhook ports
EXPOSE 8081
EXPOSE 8082
EXPOSE 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot"
LABEL org.opencontainers.image.description="Telegram bot for RemnaWave VPN service"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
LABEL org.opencontainers.image.revision="${VCS_REF}"
LABEL org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot"
LABEL org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot"
LABEL org.opencontainers.image.vendor="fr1ngg"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
# Run the application
CMD ["python", "main.py"]
+14 -13
View File
@@ -316,7 +316,6 @@ TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
@@ -607,12 +606,6 @@ services:
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
- bot_network
user: "1000:1000"
command: >
bash -c "
mkdir -p /app/logs /app/data &&
python main.py
"
volumes:
postgres_data:
@@ -786,6 +779,7 @@ bedolaga_bot/
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Рефералы
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
@@ -1013,22 +1007,22 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<tr>
<td>🥇</td>
<td><strong>Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td><strong>@pilot_737800</strong></td>
<td>₽2,750</td>
<td>За веру в проект с самого начала</td>
</tr>
<tr>
<td>🥈</td>
<td><strong>@Legacyyy777</strong></td>
<td>₽2,400</td>
<td>₽2,600</td>
<td>За ценные предложения по улучшению</td>
</tr>
<tr>
<td>🥉</td>
<td><strong>@pilot_737800</strong></td>
<td>₽2,250</td>
<td><strong>Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td>За активное тестирование и фидбек</td>
</tr>
@@ -1048,13 +1042,20 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<tr>
<td>6</td>
<td><strong>@kr33njee</strong></td>
<td>₽800</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>7</td>
<td><strong>@SmartSystemCompany</strong></td>
<td>₽600</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>7</td>
<td>8</td>
<td><strong>@PhiLin58</strong></td>
<td>₽300</td>
<td>За участие в развитии</td>
+9 -2
View File
@@ -23,7 +23,9 @@ from app.handlers.admin import (
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance
maintenance as admin_maintenance,
user_messages as admin_user_messages,
updates as admin_updates
)
from app.handlers.stars_payments import register_stars_handlers
@@ -67,6 +69,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AuthMiddleware())
@@ -78,6 +81,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(ThrottlingMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
@@ -85,6 +89,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
@@ -97,11 +102,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
logger.info(" Зарегистрированы обработчики Telegram Stars платежей")
try:
await maintenance_service.start_monitoring()
+76 -9
View File
@@ -15,7 +15,18 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
DATABASE_URL: str
DATABASE_URL: str = ""
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "remnawave_bot"
POSTGRES_USER: str = "remnawave_user"
POSTGRES_PASSWORD: str = "secure_password_123"
SQLITE_PATH: str = "./data/bot.db"
DATABASE_MODE: str = "auto"
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
@@ -93,7 +104,6 @@ class Settings(BaseSettings):
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: Optional[str] = None
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
@@ -115,7 +125,6 @@ class Settings(BaseSettings):
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -132,6 +141,10 @@ class Settings(BaseSettings):
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
VERSION_CHECK_ENABLED: bool = True
VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot"
VERSION_CHECK_INTERVAL_HOURS: int = 1
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -140,6 +153,48 @@ class Settings(BaseSettings):
log_path.parent.mkdir(parents=True, exist_ok=True)
return str(log_path)
def get_database_url(self) -> str:
if self.DATABASE_URL and self.DATABASE_URL.strip():
return self.DATABASE_URL
mode = self.DATABASE_MODE.lower()
if mode == "sqlite":
return self._get_sqlite_url()
elif mode == "postgresql":
return self._get_postgresql_url()
elif mode == "auto":
if os.getenv("DOCKER_ENV") == "true" or os.path.exists("/.dockerenv"):
return self._get_postgresql_url()
else:
return self._get_sqlite_url()
else:
return self._get_auto_database_url()
def _get_sqlite_url(self) -> str:
sqlite_path = Path(self.SQLITE_PATH)
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite+aiosqlite:///{sqlite_path.absolute()}"
def _get_postgresql_url(self) -> str:
return (f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}")
def _get_auto_database_url(self) -> str:
if (os.getenv("DOCKER_ENV") == "true" or
os.path.exists("/.dockerenv")):
return self._get_postgresql_url()
else:
return self._get_sqlite_url()
def is_postgresql(self) -> bool:
"""Проверяет, используется ли PostgreSQL"""
return "postgresql" in self.get_database_url()
def is_sqlite(self) -> bool:
"""Проверяет, используется ли SQLite"""
return "sqlite" in self.get_database_url()
def is_admin(self, user_id: int) -> bool:
return user_id in self.get_admin_ids()
@@ -180,8 +235,8 @@ class Settings(BaseSettings):
return ["ru", "en"]
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks / 100
return f"{rubles:.2f}"
rubles = price_kopeks // 100
return f"{rubles}"
def kopeks_to_rubles(self, kopeks: int) -> float:
return kopeks / 100
@@ -252,7 +307,7 @@ class Settings(BaseSettings):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if period in PERIOD_PRICES:
if hasattr(self, f'PRICE_{period}_DAYS'):
periods.append(period)
return periods if periods else [30, 90, 180]
@@ -271,7 +326,7 @@ class Settings(BaseSettings):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if period in PERIOD_PRICES:
if hasattr(self, f'PRICE_{period}_DAYS'):
periods.append(period)
return periods if periods else [30, 90, 180]
@@ -376,6 +431,15 @@ class Settings(BaseSettings):
except Exception as e:
logger.info(f"ERROR PARSING CONFIG: {e}")
return self._get_fallback_traffic_packages()
def is_version_check_enabled(self) -> bool:
return self.VERSION_CHECK_ENABLED
def get_version_check_repo(self) -> str:
return self.VERSION_CHECK_REPO
def get_version_check_interval(self) -> int:
return self.VERSION_CHECK_INTERVAL_HOURS
def _get_fallback_traffic_packages(self) -> List[Dict]:
try:
@@ -456,13 +520,13 @@ class Settings(BaseSettings):
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8"
"env_file_encoding": "utf-8",
"extra": "ignore"
}
settings = Settings()
PERIOD_PRICES = {
14: settings.PRICE_14_DAYS,
30: settings.PRICE_30_DAYS,
@@ -483,3 +547,6 @@ def refresh_traffic_prices():
TRAFFIC_PRICES = get_traffic_prices()
refresh_traffic_prices()
settings._original_database_url = settings.DATABASE_URL
settings.DATABASE_URL = settings.get_database_url()
+19 -24
View File
@@ -277,12 +277,12 @@ async def get_revenue_by_period(
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
async def find_tribute_transactions_by_payment_id(
db: AsyncSession,
payment_id: str,
user_telegram_id: Optional[int] = None
) -> List[Transaction]:
"""Найти все Tribute транзакции по payment_id"""
query = select(Transaction).options(selectinload(Transaction.user))
@@ -313,18 +313,27 @@ async def check_tribute_payment_duplicate(
amount_kopeks: int,
user_telegram_id: int
) -> Optional[Transaction]:
cutoff_time = datetime.utcnow() - timedelta(hours=24)
transactions = await find_tribute_transactions_by_payment_id(
db, payment_id, user_telegram_id
)
exact_external_id = f"donation_{payment_id}"
for transaction in transactions:
if (transaction.amount_kopeks == amount_kopeks and
transaction.is_completed and
transaction.user.telegram_id == user_telegram_id):
return transaction
query = select(Transaction).options(selectinload(Transaction.user)).where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
Transaction.external_id == exact_external_id,
Transaction.amount_kopeks == amount_kopeks,
Transaction.is_completed == True,
Transaction.created_at >= cutoff_time
)
).join(User).where(User.telegram_id == user_telegram_id)
return None
result = await db.execute(query)
transaction = result.scalar_one_or_none()
if transaction:
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
return transaction
async def create_unique_tribute_transaction(
@@ -334,7 +343,6 @@ async def create_unique_tribute_transaction(
amount_kopeks: int,
description: str
) -> Transaction:
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
external_id = f"donation_{payment_id}"
@@ -356,16 +364,3 @@ async def create_unique_tribute_transaction(
external_id=external_id,
is_completed=True
)
async def get_transaction_by_id(
db: AsyncSession,
transaction_id: int
) -> Optional[Transaction]:
try:
result = await db.execute(
select(Transaction).where(Transaction.id == transaction_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Ошибка получения транзакции {transaction_id}: {e}")
return None
+5 -20
View File
@@ -48,7 +48,6 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User).where(User.referral_code == referral_code)
)
@@ -92,7 +91,8 @@ async def create_user(
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False
has_had_paid_subscription=False,
has_made_first_topup=False
)
db.add(user)
@@ -149,18 +149,6 @@ async def add_user_balance(
await db.commit()
await db.refresh(user)
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
if has_topup_keywords and not has_exclude_keywords:
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения: {e}")
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
@@ -169,7 +157,8 @@ async def add_user_balance(
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
@@ -187,11 +176,7 @@ async def add_user_balance_by_id(
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
await db.rollback()
return False
async def subtract_user_balance(
db: AsyncSession,
+157
View File
@@ -0,0 +1,157 @@
import logging
import random
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserMessage
logger = logging.getLogger(__name__)
async def create_user_message(
db: AsyncSession,
message_text: str,
created_by: int,
is_active: bool = True,
sort_order: int = 0
) -> UserMessage:
message = UserMessage(
message_text=message_text,
is_active=is_active,
sort_order=sort_order,
created_by=created_by
)
db.add(message)
await db.commit()
await db.refresh(message)
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
return message
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
result = await db.execute(
select(UserMessage).where(UserMessage.id == message_id)
)
return result.scalar_one_or_none()
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.where(UserMessage.is_active == True)
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
)
return result.scalars().all()
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
active_messages = await get_active_user_messages(db)
if not active_messages:
return None
random_message = random.choice(active_messages)
return random_message.message_text
async def get_all_user_messages(
db: AsyncSession,
offset: int = 0,
limit: int = 50
) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.order_by(UserMessage.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_messages_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(UserMessage.id)))
return result.scalar()
async def update_user_message(
db: AsyncSession,
message_id: int,
message_text: Optional[str] = None,
is_active: Optional[bool] = None,
sort_order: Optional[int] = None
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
if message_text is not None:
message.message_text = message_text
if is_active is not None:
message.is_active = is_active
if sort_order is not None:
message.sort_order = sort_order
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
logger.info(f"📝 Обновлено сообщение ID {message_id}")
return message
async def toggle_user_message_status(
db: AsyncSession,
message_id: int
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
message.is_active = not message.is_active
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
status_text = "активировано" if message.is_active else "деактивировано"
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
return message
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
message = await get_user_message_by_id(db, message_id)
if not message:
return False
await db.delete(message)
await db.commit()
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
return True
async def get_user_messages_stats(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(UserMessage.id)))
total_messages = total_result.scalar()
active_result = await db.execute(
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
)
active_messages = active_result.scalar()
return {
"total_messages": total_messages,
"active_messages": active_messages,
"inactive_messages": total_messages - active_messages
}
+21
View File
@@ -582,3 +582,24 @@ class SubscriptionServer(Base):
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
class UserMessage(Base):
__tablename__ = "user_messages"
id = Column(Integer, primary_key=True, index=True)
message_text = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_messages")
def __repr__(self):
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
+1 -1
View File
@@ -123,7 +123,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
logger.info(f"🔗 Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = {
'Authorization': f'Bearer {self.api_key}',
+3 -3
View File
@@ -45,8 +45,8 @@ class TelegramStarsService:
)
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
)
return invoice_link
@@ -80,7 +80,7 @@ class TelegramStarsService:
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
f"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
)
return {
"message_id": message.message_id,
+11 -12
View File
@@ -10,10 +10,9 @@ logger = logging.getLogger(__name__)
class TributeService:
def __init__(self):
self.api_key = settings.TRIBUTE_API_KEY
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
self.donate_link = settings.TRIBUTE_DONATE_LINK
async def create_payment_link(
@@ -38,27 +37,27 @@ class TributeService:
return None
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("Webhook secret не настроен, пропускаем проверку")
return True
if not self.api_key:
logger.warning("API key не настроен, пропускаем проверку")
return True
try:
expected_signature = hmac.new(
self.webhook_secret.encode(),
self.api_key.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ Подпись Tribute webhook проверена успешно")
else:
logger.error("❌ Неверная подпись Tribute webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи webhook: {e}")
return False
+13 -6
View File
@@ -79,7 +79,7 @@ class WebhookServer:
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature',
}
)
@@ -111,10 +111,17 @@ class WebhookServer:
status=400
)
signature = request.headers.get('X-Tribute-Signature')
signature = request.headers.get('trbt-signature')
logger.info(f"🔐 Signature: {signature}")
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
if not signature:
logger.error("❌ Отсутствует заголовок подписи Tribute webhook")
return web.json_response(
{"status": "error", "reason": "missing_signature"},
status=401
)
if settings.TRIBUTE_API_KEY:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
@@ -123,8 +130,8 @@ class WebhookServer:
{"status": "error", "reason": "invalid_signature"},
status=401
)
result = await self.tribute_service.process_webhook(payload, signature)
result = await self.tribute_service.process_webhook(payload)
if result:
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
+203 -4
View File
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
class MaintenanceStates(StatesGroup):
waiting_for_reason = State()
waiting_for_notification_message = State()
@admin_required
@@ -30,6 +31,14 @@ async def show_maintenance_panel(
status_info = maintenance_service.get_status_info()
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
panel_status = await rw_service.get_panel_status_summary()
except Exception as e:
logger.error(f"Ошибка получения статуса панели: {e}")
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
status_emoji = "🔧" if status_info["is_active"] else ""
status_text = "Включен" if status_info["is_active"] else "Выключен"
@@ -55,14 +64,19 @@ async def show_maintenance_panel(
if status_info["consecutive_failures"] > 0:
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
if panel_status.get("response_time"):
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
message_text = f"""
🔧 <b>Режим технических работ</b>
🔧 <b>Управление техническими работами</b>
{status_emoji} <b>Статус:</b> {status_text}
{api_emoji} <b>API RemnaWave:</b> {api_text}
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
{enabled_info}
{last_check_info}
{failures_info}
@@ -72,7 +86,12 @@ async def show_maintenance_panel(
await callback.message.edit_text(
message_text,
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
reply_markup=get_maintenance_keyboard(
db_user.language,
status_info["is_active"],
status_info["monitoring_active"],
panel_status.get("has_issues", False)
)
)
await callback.answer()
@@ -186,6 +205,166 @@ async def force_api_check(
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def check_panel_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("Проверка статуса панели...", show_alert=False)
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
status_data = await rw_service.check_panel_health()
status_text = {
"online": "🟢 Панель работает нормально",
"offline": "🔴 Панель недоступна",
"degraded": "🟡 Панель работает со сбоями"
}.get(status_data["status"], "❓ Статус неизвестен")
message_parts = [
f"🌐 <b>Статус панели Remnawave</b>\n",
f"{status_text}",
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
message = "\n".join(message_parts)
await callback.message.answer(message, parse_mode="HTML")
except Exception as e:
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
@admin_required
@error_handler
async def send_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
await state.set_state(MaintenanceStates.waiting_for_notification_message)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
],
[
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
await callback.message.edit_text(
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
reply_markup=keyboard
)
@admin_required
@error_handler
async def handle_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
status_map = {
"manual_notify_online": "online",
"manual_notify_offline": "offline",
"manual_notify_degraded": "degraded",
"manual_notify_maintenance": "maintenance"
}
status = status_map.get(callback.data)
if not status:
await callback.answer("Неизвестный статус")
return
await state.update_data(notification_status=status)
status_names = {
"online": "🟢 Онлайн",
"offline": "🔴 Офлайн",
"degraded": "🟡 Проблемы",
"maintenance": "🔧 Обслуживание"
}
await callback.message.edit_text(
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def process_notification_message(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
current_state = await state.get_state()
if current_state != MaintenanceStates.waiting_for_notification_message:
return
data = await state.get_data()
status = data.get("notification_status")
if not status:
await message.answer("Ошибка: статус не выбран")
await state.clear()
return
notification_message = ""
if message.text and message.text != "/skip":
notification_message = message.text[:300]
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
success = await rw_service.send_manual_status_notification(
message.bot,
status,
notification_message
)
if success:
await message.answer("✅ Уведомление отправлено")
else:
await message.answer("❌ Ошибка отправки уведомления")
except Exception as e:
logger.error(f"Ошибка отправки ручного уведомления: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
await message.answer(
"Вернуться к панели техработ:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def back_to_admin_panel(
@@ -224,6 +403,21 @@ def register_handlers(dp: Dispatcher):
F.data == "maintenance_check_api"
)
dp.callback_query.register(
check_panel_status,
F.data == "maintenance_check_panel"
)
dp.callback_query.register(
send_manual_notification,
F.data == "maintenance_manual_notify"
)
dp.callback_query.register(
handle_manual_notification,
F.data.startswith("manual_notify_")
)
dp.callback_query.register(
back_to_admin_panel,
F.data == "admin_panel"
@@ -233,3 +427,8 @@ def register_handlers(dp: Dispatcher):
process_maintenance_reason,
MaintenanceStates.waiting_for_reason
)
dp.message.register(
process_notification_message,
MaintenanceStates.waiting_for_notification_message
)
+14 -14
View File
@@ -34,7 +34,7 @@ async def show_remnawave_menu(
status_emoji = "" if connection_test["status"] == "connected" else ""
text = f"""
🖥 <b>Управление RemnaWave</b>
🖥 <b>Управление Remnawave</b>
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
@@ -116,7 +116,7 @@ async def show_system_stats(
return f" (🔺 {difference_str})"
text = f"""
📊 <b>Детальная статистика RemnaWave</b>
📊 <b>Детальная статистика Remnawave</b>
🖥 <b>Сервер:</b>
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
@@ -238,7 +238,7 @@ async def show_traffic_stats(
return f" 🔺 {diff_str}"
text = f"""
📊 <b>Статистика трафика RemnaWave</b>
📊 <b>Статистика трафика Remnawave</b>
<b>Реалтайм данные:</b>
- Скачивание: {format_bytes(total_realtime_download)}
@@ -1247,7 +1247,7 @@ async def finish_squad_creation(
f"Сквад готов к использованию!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 Список сквадов", callback_data="admin_rw_squads")],
[types.InlineKeyboardButton(text="⬅️ К панели RemnaWave", callback_data="admin_remnawave")]
[types.InlineKeyboardButton(text="⬅️ К панели Remnawave", callback_data="admin_remnawave")]
])
)
await callback.answer("✅ Сквад создан!")
@@ -1320,7 +1320,7 @@ async def show_sync_options(
db: AsyncSession
):
text = """
🔄 <b>Синхронизация с RemnaWave</b>
🔄 <b>Синхронизация с Remnawave</b>
🔄 <b>Полная синхронизация выполняет:</b>
Создание новых пользователей из панели в боте
@@ -1360,7 +1360,7 @@ async def sync_all_users(
🔄 <b>Выполняется полная синхронизация...</b>
📋 Этапы:
Загрузка ВСЕХ пользователей из панели RemnaWave
Загрузка ВСЕХ пользователей из панели Remnawave
Создание новых пользователей в боте
Обновление существующих пользователей
Деактивация подписок отсутствующих пользователей
@@ -1401,7 +1401,7 @@ async def sync_all_users(
🗑 <b>Деактивированные подписки:</b>
Деактивированы подписки пользователей, которые
отсутствуют в панели RemnaWave.
отсутствуют в панели Remnawave.
💰 Балансы пользователей сохранены.
"""
@@ -1546,7 +1546,7 @@ async def validate_subscriptions(
if stats['fixed'] > 0:
text += "\n✅ <b>Исправленные проблемы:</b>\n"
text += "• Статусы просроченных подписок\n"
text += "• Отсутствующие данные RemnaWave\n"
text += "• Отсутствующие данные Remnawave\n"
text += "• Некорректные лимиты трафика\n"
text += "• Настройки устройств\n"
@@ -1600,7 +1600,7 @@ async def cleanup_subscriptions(
if stats['deactivated'] > 0:
text += f"\n🗑️ <b>Деактивированные подписки:</b>\n"
text += f"Отключены подписки пользователей, которые\n"
text += f"отсутствуют в панели RemnaWave.\n"
text += f"отсутствуют в панели Remnawave.\n"
else:
text += f"\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено."
@@ -1664,8 +1664,8 @@ async def force_cleanup_all_orphaned_users(
Удалены использования промокодов
Сброшены балансы к нулю
Удалены подключенные серверы
Сброшены HWID устройства в RemnaWave
Очищены RemnaWave UUID
Сброшены HWID устройства в Remnawave
Очищены Remnawave UUID
"""
else:
text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью."
@@ -1703,8 +1703,8 @@ async def confirm_force_cleanup(
ВСЕ использования промокодов
ВСЕ подключенные серверы подписок
ВСЕ балансы (сброс к нулю)
ВСЕ HWID устройства в RemnaWave
ВСЕ RemnaWave UUID и ссылки
ВСЕ HWID устройства в Remnawave
ВСЕ Remnawave UUID и ссылки
<b>Это действие НЕОБРАТИМО!</b>
@@ -1813,7 +1813,7 @@ async def sync_users(
if sync_type == "all_users" and 'deleted' in stats and stats['deleted'] > 0:
text += f"\n🗑️ <b>Удаленные подписки:</b>\n"
text += f"Деактивированы подписки пользователей,\n"
text += f"которые отсутствуют в панели RemnaWave."
text += f"которые отсутствуют в панели Remnawave."
text += f"\n\n💡 <b>Рекомендации:</b>\n"
if sync_type == "all_users":
+11 -11
View File
@@ -37,7 +37,7 @@ async def show_servers_menu(
С подключениями: {stats['servers_with_connections']}
💰 <b>Выручка от серверов:</b>
Общая: {stats['total_revenue_rubles']:.2f}
Общая: {int(stats['total_revenue_rubles'])}
Выберите действие:
"""
@@ -83,7 +83,7 @@ async def show_servers_list(
for i, server in enumerate(servers, 1 + (page - 1) * 10):
status_emoji = "" if server.is_available else ""
price_text = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text += f"{i}. {status_emoji} {server.display_name}\n"
text += f" 💰 Цена: {price_text}"
@@ -147,7 +147,7 @@ async def sync_servers_with_remnawave(
):
await callback.message.edit_text(
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
"🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.",
reply_markup=None
)
@@ -157,7 +157,7 @@ async def sync_servers_with_remnawave(
if not squads:
await callback.message.edit_text(
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
"❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
@@ -222,7 +222,7 @@ async def show_server_edit_menu(
return
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -304,7 +304,7 @@ async def toggle_server_availability(
server = await get_server_squad_by_id(db, server_id)
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -378,7 +378,7 @@ async def start_server_edit_price(
await state.set_data({'server_id': server_id})
await state.set_state(AdminStates.editing_server_price)
current_price = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
current_price = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
await callback.message.edit_text(
f"💰 <b>Редактирование цены</b>\n\n"
@@ -424,7 +424,7 @@ async def process_server_price_edit(
await cache.delete("available_countries")
price_text = f"{price_rubles:.2f}" if price_kopeks > 0 else "Бесплатно"
price_text = f"{int(price_rubles)}" if price_kopeks > 0 else "Бесплатно"
await message.answer(
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -613,8 +613,8 @@ async def show_server_detailed_stats(
С активными подключениями: {stats['servers_with_connections']}
<b>💰 Финансовая статистика:</b>
Общая выручка: {stats['total_revenue_rubles']:.2f}
Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f}
Общая выручка: {int(stats['total_revenue_rubles'])}
Средняя цена за сервер: {int(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1))}
<b>🔥 Топ серверов по цене:</b>
"""
@@ -622,7 +622,7 @@ async def show_server_detailed_stats(
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
for i, server in enumerate(sorted_servers[:5], 1):
price_text = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text += f"{i}. {server.display_name} - {price_text}\n"
if not sorted_servers:
+274
View File
@@ -0,0 +1,274 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.version_service import version_service
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
text="🔄 Проверить обновления",
callback_data="admin_updates_check"
)
],
[
InlineKeyboardButton(
text="📋 Информация о версии",
callback_data="admin_updates_info"
)
],
[
InlineKeyboardButton(
text="🔗 Открыть репозиторий",
url=f"https://github.com/{version_service.repo}/releases"
)
],
[
InlineKeyboardButton(
text="◀️ Назад",
callback_data="admin_panel"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data="admin_updates_info"
)
],
[
InlineKeyboardButton(
text="◀️ К обновлениям",
callback_data="admin_updates"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@admin_required
@error_handler
async def show_updates_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
version_info = await version_service.get_version_info()
current_version = version_info['current_version']
has_updates = version_info['has_updates']
total_newer = version_info['total_newer']
last_check = version_info['last_check']
status_icon = "🆕" if has_updates else ""
status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия"
last_check_text = ""
if last_check:
last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}"
message = f"""🔄 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
{status_icon} <b>Статус:</b> {status_text}
🔗 <b>Репозиторий:</b> {version_service.repo}{last_check_text}
Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях."""
await callback.message.edit_text(
message,
reply_markup=get_updates_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка показа меню обновлений: {e}")
await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True)
@admin_required
@error_handler
async def check_updates(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("🔄 Проверяю обновления...")
try:
has_updates, newer_releases = await version_service.check_for_updates(force=True)
if not has_updates:
message = f"""✅ <b>ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ</b>
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
🎯 <b>Статус:</b> У вас установлена последняя версия
🔗 <b>Репозиторий:</b> {version_service.repo}"""
else:
updates_list = []
for i, release in enumerate(newer_releases[:5]):
icon = version_service.format_version_display(release).split()[0]
updates_list.append(
f"{i+1}. {icon} <code>{release.tag_name}</code> • {release.formatted_date}"
)
updates_text = "\n".join(updates_list)
more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else ""
message = f"""🆕 <b>НАЙДЕНЫ ОБНОВЛЕНИЯ</b>
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
🎯 <b>Доступно обновлений:</b> {len(newer_releases)}
📋 <b>Последние версии:</b>
{updates_text}{more_text}
🔗 <b>Репозиторий:</b> {version_service.repo}"""
keyboard = get_updates_keyboard(db_user.language)
if has_updates:
keyboard.inline_keyboard.insert(-2, [
InlineKeyboardButton(
text="📋 Подробнее о версиях",
callback_data="admin_updates_info"
)
])
await callback.message.edit_text(
message,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка проверки обновлений: {e}")
await callback.message.edit_text(
f"❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n"
f"Не удалось связаться с сервером GitHub.\n"
f"Попробуйте позже.\n\n"
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
reply_markup=get_updates_keyboard(db_user.language),
parse_mode="HTML"
)
@admin_required
@error_handler
async def show_version_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("📋 Загружаю информацию о версиях...")
try:
version_info = await version_service.get_version_info()
current_version = version_info['current_version']
current_release = version_info['current_release']
newer_releases = version_info['newer_releases']
has_updates = version_info['has_updates']
last_check = version_info['last_check']
repo_url = version_info['repo_url']
current_info = f"📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n"
if current_release:
current_info += f"🏷️ <b>Версия:</b> <code>{current_release.tag_name}</code>\n"
current_info += f"📅 <b>Дата релиза:</b> {current_release.formatted_date}\n"
if current_release.short_description:
current_info += f"📝 <b>Описание:</b>\n{current_release.short_description}\n"
else:
current_info += f"🏷️ <b>Версия:</b> <code>{current_version}</code>\n"
current_info += f"️ <b>Статус:</b> Информация о релизе недоступна\n"
message_parts = [current_info]
if has_updates and newer_releases:
updates_info = f"\n🆕 <b>ДОСТУПНЫЕ ОБНОВЛЕНИЯ</b>\n\n"
for i, release in enumerate(newer_releases):
icon = "🔥" if i == 0 else "📦"
if release.prerelease:
icon = "🧪"
elif release.is_dev:
icon = "🔧"
updates_info += f"{icon} <b>{release.tag_name}</b>\n"
updates_info += f" 📅 {release.formatted_date}\n"
if release.short_description:
updates_info += f" 📝 {release.short_description}\n"
updates_info += "\n"
message_parts.append(updates_info.rstrip())
system_info = f"\n🔧 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>\n\n"
system_info += f"🔗 <b>Репозиторий:</b> {version_service.repo}\n"
system_info += f"⚡ <b>Автопроверка:</b> {'Включена' if version_service.enabled else 'Отключена'}\n"
system_info += f"🕐 <b>Интервал:</b> Каждый час\n"
if last_check:
system_info += f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%d.%m.%Y %H:%M')}\n"
message_parts.append(system_info.rstrip())
final_message = "\n".join(message_parts)
if len(final_message) > 4000:
final_message = final_message[:3900] + "\n\n... (информация обрезана)"
await callback.message.edit_text(
final_message,
reply_markup=get_version_info_keyboard(db_user.language),
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logger.error(f"Ошибка получения информации о версиях: {e}")
await callback.message.edit_text(
f"❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n"
f"Не удалось получить информацию о версиях.\n\n"
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
reply_markup=get_version_info_keyboard(db_user.language),
parse_mode="HTML"
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_updates_menu,
F.data == "admin_updates"
)
dp.callback_query.register(
check_updates,
F.data == "admin_updates_check"
)
dp.callback_query.register(
show_version_info,
F.data == "admin_updates_info"
)
+584
View File
@@ -0,0 +1,584 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user_message import (
create_user_message, get_all_user_messages, get_user_message_by_id,
update_user_message, delete_user_message, toggle_user_message_status,
get_user_messages_stats
)
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.utils.decorators import admin_required, error_handler
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
class UserMessageStates(StatesGroup):
waiting_for_message_text = State()
waiting_for_edit_text = State()
def get_user_messages_keyboard(language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Добавить сообщение",
callback_data="add_user_message"
)
],
[
InlineKeyboardButton(
text="📋 Список сообщений",
callback_data="list_user_messages:0"
)
],
[
InlineKeyboardButton(
text="📊 Статистика",
callback_data="user_messages_stats"
)
],
[
InlineKeyboardButton(
text="🔙 Назад в админку",
callback_data="admin_panel"
)
]
])
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=f"edit_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text=status_text,
callback_data=f"toggle_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🗑️ Удалить",
callback_data=f"delete_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🔙 К списку",
callback_data="list_user_messages:0"
)
]
])
@admin_required
@error_handler
async def show_user_messages_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
text = (
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
"в главном меню между информацией о подписке и кнопками действий.\n\n"
"• Сообщения поддерживают HTML теги\n"
"• Можно создать несколько сообщений\n"
"• Активные сообщения показываются случайно\n"
"• Неактивные сообщения не показываются"
)
await callback.message.edit_text(
text,
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def add_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
from app.utils.validators import get_html_help_text
await callback.message.edit_text(
f"📝 <b>Добавление нового сообщения</b>\n\n"
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
f"{get_html_help_text()}\n\n"
f"Отправьте /cancel для отмены.",
parse_mode="HTML"
)
await state.set_state(UserMessageStates.waiting_for_message_text)
await callback.answer()
@admin_required
@error_handler
async def process_new_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Добавление сообщения отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
message_text = message.text.strip()
if len(message_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
from app.utils.validators import validate_html_tags, get_html_help_text
is_valid, error_msg = validate_html_tags(message_text)
if not is_valid:
await message.answer(
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
parse_mode=None
)
return
try:
new_message = await create_user_message(
db=db,
message_text=message_text,
created_by=db_user.id,
is_active=True
)
await state.clear()
await message.answer(
f"✅ <b>Сообщение добавлено!</b>\n\n"
f"<b>ID:</b> {new_message.id}\n"
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Предварительный просмотр:</b>\n"
f"<blockquote>{message_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
@admin_required
@error_handler
async def list_user_messages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = 0
if ":" in callback.data:
try:
page = int(callback.data.split(":")[1])
except (ValueError, IndexError):
page = 0
limit = 5
offset = page * limit
messages = await get_all_user_messages(db, offset=offset, limit=limit)
if not messages:
await callback.message.edit_text(
"📋 <b>Список сообщений</b>\n\n"
"Сообщений пока нет. Добавьте первое сообщение!",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
return
text = "📋 <b>Список сообщений</b>\n\n"
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
preview = preview.replace('<', '&lt;').replace('>', '&gt;')
text += (
f"{status_emoji} <b>ID {msg.id}</b>\n"
f"{preview}\n"
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = []
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
keyboard.append([
InlineKeyboardButton(
text=f"{status_emoji} ID {msg.id}",
callback_data=f"view_user_message:{msg.id}"
)
])
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"list_user_messages:{page-1}"
)
)
nav_buttons.append(
InlineKeyboardButton(
text=" Добавить",
callback_data="add_user_message"
)
)
if len(messages) == limit:
nav_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=f"list_user_messages:{page+1}"
)
)
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def view_user_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
text = (
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
f"<b>Статус:</b> {status_text}\n"
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Содержимое:</b>\n"
f"<blockquote>{message.message_text}</blockquote>"
)
await callback.message.edit_text(
text,
reply_markup=get_message_actions_keyboard(
message_id, message.is_active, db_user.language
),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_message_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await toggle_user_message_status(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "активировано" if message.is_active else "деактивировано"
await callback.answer(f"✅ Сообщение {status_text}")
await view_user_message(callback, db_user, db)
@admin_required
@error_handler
async def delete_message_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтвердить удаление сообщения"""
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
success = await delete_user_message(db, message_id)
if success:
await callback.answer("✅ Сообщение удалено")
await list_user_messages(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data="list_user_messages:0",
message=callback.message
),
db_user,
db
)
else:
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
@admin_required
@error_handler
async def show_messages_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_user_messages_stats(db)
text = (
"📊 <b>Статистика сообщений</b>\n\n"
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
"Активные сообщения показываются пользователям случайным образом "
"в главном меню между информацией о подписке и кнопками действий."
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
]
])
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def edit_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
await callback.message.edit_text(
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
f"<b>Текущий текст:</b>\n"
f"<blockquote>{message.message_text}</blockquote>\n\n"
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
parse_mode="HTML"
)
await state.set_data({"editing_message_id": message_id})
await state.set_state(UserMessageStates.waiting_for_edit_text)
await callback.answer()
@admin_required
@error_handler
async def process_edit_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Редактирование отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
data = await state.get_data()
message_id = data.get("editing_message_id")
if not message_id:
await state.clear()
await message.answer("❌ Ошибка: ID сообщения не найден")
return
new_text = message.text.strip()
if len(new_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
try:
updated_message = await update_user_message(
db=db,
message_id=message_id,
message_text=new_text
)
if updated_message:
await state.clear()
await message.answer(
f"✅ <b>Сообщение обновлено!</b>\n\n"
f"<b>ID:</b> {updated_message.id}\n"
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Новый текст:</b>\n"
f"<blockquote>{new_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await state.clear()
await message.answer(
"❌ Сообщение не найдено или ошибка обновления.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
except Exception as e:
logger.error(f"Ошибка обновления сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при обновлении сообщения.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_messages_panel,
F.data == "user_messages_panel"
)
dp.callback_query.register(
add_user_message_start,
F.data == "add_user_message"
)
dp.message.register(
process_new_message_text,
StateFilter(UserMessageStates.waiting_for_message_text)
)
dp.callback_query.register(
edit_user_message_start,
F.data.startswith("edit_user_message:")
)
dp.message.register(
process_edit_message_text,
StateFilter(UserMessageStates.waiting_for_edit_text)
)
dp.callback_query.register(
list_user_messages,
F.data.startswith("list_user_messages")
)
dp.callback_query.register(
view_user_message,
F.data.startswith("view_user_message:")
)
dp.callback_query.register(
toggle_message_status,
F.data.startswith("toggle_user_message:")
)
dp.callback_query.register(
delete_message_confirm,
F.data.startswith("delete_user_message:")
)
dp.callback_query.register(
show_messages_stats,
F.data == "user_messages_stats"
)
+146 -2
View File
@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -292,6 +293,12 @@ async def show_user_subscription(
text="📊 Трафик",
callback_data=f"admin_sub_traffic_{user_id}"
)
],
[
types.InlineKeyboardButton(
text="🔄 Тип подписки",
callback_data=f"admin_sub_change_type_{user_id}"
)
]
]
@@ -629,9 +636,9 @@ async def process_balance_edit(
description = f"Изменение баланса администратором {db_user.full_name}"
if amount_kopeks > 0:
description = f"Пополнение администратором: +{amount_rubles}"
description = f"Пополнение администратором: +{int(amount_rubles)}"
else:
description = f"Списание администратором: {amount_rubles}"
description = f"Списание администратором: {int(amount_rubles)}"
success = await user_service.update_user_balance(
db, user_id, amount_kopeks, description, db_user.id
@@ -2206,6 +2213,133 @@ async def cleanup_inactive_users(
)
await callback.answer()
@admin_required
@error_handler
async def change_subscription_type(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile or not profile["subscription"]:
await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True)
return
subscription = profile["subscription"]
current_type = "🎁 Триал" if subscription.is_trial else "💎 Платная"
text = f"🔄 <b>Смена типа подписки</b>\n\n"
text += f"👤 {profile['user'].full_name}\n"
text += f"📱 Текущий тип: {current_type}\n\n"
text += f"Выберите новый тип подписки:"
keyboard = []
if subscription.is_trial:
keyboard.append([
InlineKeyboardButton(
text="💎 Сделать платной",
callback_data=f"admin_sub_type_paid_{user_id}"
)
])
else:
keyboard.append([
InlineKeyboardButton(
text="🎁 Сделать триальной",
callback_data=f"admin_sub_type_trial_{user_id}"
)
])
keyboard.append([
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"admin_user_subscription_{user_id}"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def change_subscription_type_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
new_type = parts[-2] # 'paid' или 'trial'
user_id = int(parts[-1])
success = await _change_subscription_type(db, user_id, new_type, db_user.id)
if success:
type_text = "платной" if new_type == "paid" else "триальной"
await callback.message.edit_text(
f"✅ Тип подписки успешно изменен на {type_text}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка изменения типа подписки",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: str, admin_id: int) -> bool:
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
logger.error(f"Подписка не найдена для пользователя {user_id}")
return False
new_is_trial = (new_type == "trial")
if subscription.is_trial == new_is_trial:
logger.info(f"Тип подписки уже установлен корректно для пользователя {user_id}")
return True
old_type = "триальной" if subscription.is_trial else "платной"
new_type_text = "триальной" if new_is_trial else "платной"
subscription.is_trial = new_is_trial
subscription.updated_at = datetime.utcnow()
if not new_is_trial and subscription.is_trial:
user = await get_user_by_id(db, user_id)
if user:
user.has_had_paid_subscription = True
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} изменил тип подписки пользователя {user_id}: {old_type} -> {new_type_text}")
return True
except Exception as e:
logger.error(f"Ошибка изменения типа подписки: {e}")
await db.rollback()
return False
def register_handlers(dp: Dispatcher):
@@ -2429,3 +2563,13 @@ def register_handlers(dp: Dispatcher):
reset_user_devices,
F.data.startswith("admin_user_reset_devices_confirm_")
)
dp.callback_query.register(
change_subscription_type,
F.data.startswith("admin_sub_change_type_")
)
dp.callback_query.register(
change_subscription_type_confirm,
F.data.startswith("admin_sub_type_")
)
+2 -2
View File
@@ -381,7 +381,7 @@ async def process_stars_payment_amount(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
f"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
@@ -465,7 +465,7 @@ async def process_yookassa_payment_amount(
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
+52 -7
View File
@@ -10,6 +10,7 @@ from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.crud.user_message import get_random_active_message
logger = logging.getLogger(__name__)
@@ -31,10 +32,7 @@ async def show_main_menu(
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = texts.MAIN_MENU.format(
user_name=db_user.full_name,
subscription_status=_get_subscription_status(db_user, texts)
)
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
@@ -45,7 +43,8 @@ async def show_main_menu(
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks
)
),
parse_mode="HTML"
)
await callback.answer()
@@ -100,10 +99,31 @@ async def handle_back_to_menu(
db_user: User,
db: AsyncSession
):
await state.clear()
await show_main_menu(callback, db_user, db)
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks
),
parse_mode="HTML"
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
@@ -136,6 +156,31 @@ def _get_subscription_status(user: User, texts) -> str:
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
+69 -7
View File
@@ -1,12 +1,21 @@
import logging
from aiogram import Dispatcher, types, F
from pathlib import Path
import qrcode
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
from app.utils.user_utils import (
get_detailed_referral_list,
get_referral_analytics,
get_user_referral_summary,
)
logger = logging.getLogger(__name__)
@@ -82,11 +91,59 @@ async def show_referral_info(
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
await callback.message.edit_text(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
if callback.message.text:
await callback.message.edit_text(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await callback.message.delete()
await callback.message.answer(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
qr_dir = Path("data") / "referral_qr"
qr_dir.mkdir(parents=True, exist_ok=True)
file_path = qr_dir / f"{db_user.id}.png"
if not file_path.exists():
img = qrcode.make(referral_link)
img.save(file_path)
photo = FSInputFile(file_path)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
)
try:
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
reply_markup=keyboard,
)
await callback.answer()
@@ -243,6 +300,11 @@ def register_handlers(dp: Dispatcher):
create_invite_message,
F.data == "referral_create_invite"
)
dp.callback_query.register(
show_referral_qr,
F.data == "referral_show_qr"
)
dp.callback_query.register(
show_detailed_referral_list,
+2 -2
View File
@@ -92,7 +92,7 @@ async def handle_successful_payment(
await message.answer(
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
f"⭐ Потрачено звезд: {payment.total_amount}\n"
f"💰 Зачислено на баланс: {rubles_amount:.2f}\n"
f"💰 Зачислено на баланс: {int(rubles_amount)}\n"
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
f"Спасибо за пополнение! 🚀",
parse_mode="HTML"
@@ -100,7 +100,7 @@ async def handle_successful_payment(
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}"
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}"
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
+117 -88
View File
@@ -17,6 +17,7 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
logger = logging.getLogger(__name__)
@@ -136,11 +137,10 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if user.subscription:
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
await message.answer(
texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
@@ -148,7 +148,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
),
parse_mode="HTML"
)
await state.clear()
return
@@ -405,30 +406,24 @@ async def complete_registration_from_callback(
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
menu_text = await get_main_menu_text(existing_user, texts, db)
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status(existing_user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
balance_kopeks=existing_user.balance_kopeks
),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await callback.message.answer(f"Добро пожаловать, {user_name}!")
await callback.message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await state.clear()
return
@@ -506,55 +501,50 @@ async def complete_registration_from_callback(
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
balance_rubles = user.balance_kopeks // 100
await callback.message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles:.2f}\n"
f"Добро пожаловать, {user.full_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
balance_kopeks=user.balance_kopeks
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
except Exception as fallback_error:
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
await callback.message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except Exception as final_error:
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
async def complete_registration(
message: types.Message,
@@ -579,30 +569,24 @@ async def complete_registration(
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
menu_text = await get_main_menu_text(existing_user, texts, db)
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status(existing_user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
balance_kopeks=existing_user.balance_kopeks
),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(f"Добро пожаловать, {user_name}!")
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await state.clear()
return
@@ -680,55 +664,51 @@ async def complete_registration(
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
balance_rubles = user.balance_kopeks // 100
await message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles:.2f}\n"
f"Добро пожаловать, {user.full_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
except Exception as fallback_error:
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
await message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except:
pass
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
def _get_subscription_status(user, texts):
@@ -787,6 +767,55 @@ def get_referral_code_keyboard(language: str):
)]
])
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
+40 -39
View File
@@ -34,7 +34,8 @@ from app.keyboards.inline import (
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_subscription_settings_keyboard, get_extend_subscription_keyboard_with_prices
get_subscription_settings_keyboard, get_extend_subscription_keyboard_with_prices,
get_insufficient_balance_keyboard
)
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
@@ -376,7 +377,7 @@ async def activate_trial(
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db_user, subscription)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
@@ -1200,7 +1201,7 @@ async def confirm_extend_subscription(
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db_user, subscription, transaction, days, old_end_date
db, db_user, subscription, transaction, days, old_end_date
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
@@ -1415,13 +1416,13 @@ async def get_traffic_packages_info() -> str:
info_lines.append("\n✅ Активные:")
for pkg in enabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price']/100}")
info_lines.append(f"{gb_text}: {pkg['price']//100}")
if disabled_packages:
info_lines.append("\n❌ Отключенные:")
for pkg in disabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price']/100}")
info_lines.append(f"{gb_text}: {pkg['price']//100}")
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
@@ -1796,9 +1797,10 @@ async def confirm_purchase(
logger.info(f" ИТОГО: {final_price/100}")
if db_user.balance_kopeks < final_price:
missing_kopeks = final_price - db_user.balance_kopeks
await callback.message.edit_text(
texts.INSUFFICIENT_BALANCE,
reply_markup=get_back_keyboard(db_user.language)
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
reply_markup=get_insufficient_balance_keyboard(db_user.language),
)
await callback.answer()
return
@@ -1810,9 +1812,10 @@ async def confirm_purchase(
)
if not success:
missing_kopeks = final_price - db_user.balance_kopeks
await callback.message.edit_text(
texts.INSUFFICIENT_BALANCE,
reply_markup=get_back_keyboard(db_user.language)
texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)),
reply_markup=get_insufficient_balance_keyboard(db_user.language),
)
await callback.answer()
return
@@ -1908,7 +1911,7 @@ async def confirm_purchase(
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db_user, subscription, transaction, data['period_days'], was_trial_conversion
db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
@@ -1918,9 +1921,9 @@ async def confirm_purchase(
if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url:
success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
success_text += f"Ваша ссылка для импорта в VPN приложение:\n"
success_text += f"🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n"
success_text += f"<code>{subscription.subscription_url}</code>\n\n"
success_text += f"Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
success_text += f"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -1928,12 +1931,12 @@ async def confirm_purchase(
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="Подключиться",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url),
)
],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
@@ -1943,18 +1946,18 @@ async def confirm_purchase(
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="Подключиться",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Подключиться", callback_data="subscription_connect")],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
await callback.message.edit_text(
@@ -2994,15 +2997,16 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa
]
])
async def send_trial_notification(callback: types.CallbackQuery, db_user: User, subscription: Subscription):
async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, subscription: Subscription):
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db_user, subscription)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
async def send_purchase_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
@@ -3011,20 +3015,19 @@ async def send_purchase_notification(
):
try:
from app.database.crud.transaction import get_transaction_by_id
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db_user, subscription, transaction, period_days, was_trial_conversion
)
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
async def send_extension_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
@@ -3033,15 +3036,13 @@ async def send_extension_notification(
):
try:
from app.database.crud.transaction import get_transaction_by_id
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db_user, subscription, transaction, extended_days, old_end_date
)
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db, db_user, subscription, transaction, extended_days, old_end_date
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
+1 -1
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def tribute_webhook(request):
try:
signature = request.headers.get('X-Signature', '')
signature = request.headers.get('trbt-signature', '')
payload = await request.text()
tribute_service = TributeService()
+67 -16
View File
@@ -28,6 +28,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
],
[
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
]
@@ -580,25 +584,72 @@ def get_admin_pagination_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
def get_maintenance_keyboard(
language: str,
is_maintenance_active: bool,
is_monitoring_active: bool,
panel_has_issues: bool = False
) -> InlineKeyboardMarkup:
keyboard = []
if language == "en":
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
check_api_text = "🔍 Check API"
back_text = "⬅️ Back to admin"
if is_maintenance_active:
keyboard.append([
InlineKeyboardButton(
text="🟢 Выключить техработы",
callback_data="maintenance_toggle"
)
])
else:
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
check_api_text = "🔍 Проверить API"
back_text = "⬅️ Назад в админку"
keyboard.append([
InlineKeyboardButton(
text="🔧 Включить техработы",
callback_data="maintenance_toggle"
)
])
keyboard = [
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
]
if is_monitoring_active:
keyboard.append([
InlineKeyboardButton(
text="⏹️ Остановить мониторинг",
callback_data="maintenance_monitoring"
)
])
else:
keyboard.append([
InlineKeyboardButton(
text="▶️ Запустить мониторинг",
callback_data="maintenance_monitoring"
)
])
keyboard.append([
InlineKeyboardButton(
text="🔍 Проверить API",
callback_data="maintenance_check_api"
),
InlineKeyboardButton(
text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""),
callback_data="maintenance_check_panel"
)
])
keyboard.append([
InlineKeyboardButton(
text="📢 Отправить уведомление",
callback_data="maintenance_manual_notify"
)
])
keyboard.append([
InlineKeyboardButton(
text="🔄 Обновить",
callback_data="maintenance_panel"
),
InlineKeyboardButton(
text="◀️ Назад",
callback_data="admin_panel"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+39 -20
View File
@@ -104,6 +104,19 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.GO_TO_BALANCE_TOP_UP,
callback_data="balance_topup",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")],
])
def get_subscription_keyboard(
language: str = "ru",
has_subscription: bool = False,
@@ -415,15 +428,7 @@ def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -435,11 +440,19 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="💎 Банковская карта (Tribute)",
text="💳 Банковская карта (Tribute)",
callback_data="topup_tribute"
)
])
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
keyboard.append([
InlineKeyboardButton(
text="🛠️ Через поддержку",
@@ -533,6 +546,12 @@ def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="referral_create_invite"
)
],
[
InlineKeyboardButton(
text="📱 Показать QR код",
callback_data="referral_show_qr"
)
],
[
InlineKeyboardButton(
text="👥 Список рефералов",
@@ -715,14 +734,14 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti
if gb == 0:
if language == "ru":
text = f"♾️ Безлимитный трафик - {total_price/100:.2f}{period_text}"
text = f"♾️ Безлимитный трафик - {total_price//100}{period_text}"
else:
text = f"♾️ Unlimited traffic - {total_price/100:.2f}{period_text}"
text = f"♾️ Unlimited traffic - {total_price//100}{period_text}"
else:
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f}{period_text}"
text = f"📊 +{gb} ГБ трафика - {total_price//100}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price/100:.2f}{period_text}"
text = f"📊 +{gb} GB traffic - {total_price//100}{period_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
@@ -761,9 +780,9 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscri
total_price = price_per_month * months_multiplier
if language == "ru":
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price/100:.2f}{period_text}"
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price//100}{period_text}"
else:
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price/100:.2f}{period_text}"
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price//100}{period_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
@@ -835,10 +854,10 @@ def get_manage_countries_keyboard(
if uuid not in current_subscription_countries and uuid in selected:
total_price = price_per_month * months_multiplier
if months_multiplier > 1:
price_text = f" ({price_per_month/100:.2f}₽/мес × {months_multiplier} = {total_price/100:.2f}₽)"
price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)"
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}")
else:
price_text = f" ({total_price/100:.2f}₽)"
price_text = f" ({total_price//100}₽)"
display_name = f"{icon} {name}{price_text}"
else:
display_name = f"{icon} {name}"
@@ -851,7 +870,7 @@ def get_manage_countries_keyboard(
])
if total_cost > 0:
apply_text = f"✅ Применить изменения ({total_cost/100:.2f} ₽)"
apply_text = f"✅ Применить изменения ({total_cost//100} ₽)"
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}")
else:
apply_text = "✅ Применить изменения"
+12 -4
View File
@@ -57,7 +57,7 @@ class Texts:
@staticmethod
def format_price(kopeks: int) -> str:
return f"{kopeks / 100:.2f}"
return f"{int(kopeks / 100)}"
@staticmethod
def format_traffic(gb: float) -> str:
@@ -125,7 +125,7 @@ class RussianTexts(Texts):
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
@@ -204,7 +204,11 @@ class RussianTexts(Texts):
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
<b>Пополните баланс на {amount} и попробуйте снова.</b>
"""
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
@@ -443,10 +447,14 @@ To get started, select interface language:
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
MENU_TRIAL = "🎁 Trial subscription"
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
Top up {amount} and try again."""
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
LANGUAGES = {
+18 -14
View File
@@ -44,16 +44,18 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
is_registration_process = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (isinstance(event, CallbackQuery) and current_state and
any(str(state) in str(current_state) for state in registration_states))
or (isinstance(event, CallbackQuery) and event.data and
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
or (current_state in registration_states)
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
)
)
if is_registration_process:
@@ -93,16 +95,18 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
is_start_or_registration = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (isinstance(event, CallbackQuery) and current_state and
any(str(state) in str(current_state) for state in registration_states))
or (isinstance(event, CallbackQuery) and event.data and
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
or (current_state in registration_states)
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
)
)
if is_start_or_registration:
+440 -7
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, Subscription, Transaction
from app.database.crud.user import get_user_by_id
logger = logging.getLogger(__name__)
@@ -19,8 +20,27 @@ class AdminNotificationService:
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
if not referred_by_id:
return "Нет"
try:
referrer = await get_user_by_id(db, referred_by_id)
if not referrer:
return f"ID {referred_by_id} (не найден)"
if referrer.username:
return f"@{referrer.username} (ID: {referred_by_id})"
else:
return f"ID {referrer.telegram_id}"
except Exception as e:
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
return f"ID {referred_by_id}"
async def send_trial_activation_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> bool:
@@ -29,6 +49,7 @@ class AdminNotificationService:
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
@@ -44,7 +65,7 @@ class AdminNotificationService:
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -56,6 +77,7 @@ class AdminNotificationService:
async def send_subscription_purchase_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
@@ -76,8 +98,8 @@ class AdminNotificationService:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""💎 <b>{event_type}</b>
@@ -99,7 +121,7 @@ class AdminNotificationService:
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -108,9 +130,82 @@ class AdminNotificationService:
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:
if latest_version.prerelease:
update_type = "🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ"
type_icon = "🧪"
elif latest_version.is_dev:
update_type = "🔧 DEV ВЕРСИЯ"
type_icon = "🔧"
else:
update_type = "📦 НОВАЯ ВЕРСИЯ"
type_icon = "📦"
description = latest_version.short_description
if len(description) > 200:
description = description[:197] + "..."
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
📝 <b>Описание:</b>
{description}
🔢 <b>Всего доступно обновлений:</b> {total_updates}
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
return False
async def send_version_check_error_notification(
self,
error_message: str,
current_version: str
) -> bool:
if not self._is_enabled():
return False
try:
message = f"""⚠️ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
<b>Ошибка:</b> {error_message}
🔄 Следующая попытка через час.
Проверьте доступность GitHub API и настройки сети.
<i>Система автоматических обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
return False
async def send_balance_topup_notification(
self,
db: AsyncSession,
user: User,
transaction: Transaction,
old_balance: int
@@ -120,10 +215,9 @@ class AdminNotificationService:
try:
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
@@ -142,7 +236,7 @@ class AdminNotificationService:
📈 Стало: {settings.format_price(user.balance_kopeks)}
Изменение: +{settings.format_price(balance_change)}
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -155,6 +249,7 @@ class AdminNotificationService:
async def send_subscription_extension_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
@@ -166,7 +261,6 @@ class AdminNotificationService:
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
@@ -275,3 +369,342 @@ class AdminNotificationService:
except Exception as e:
logger.warning(f"Не удалось получить названия серверов: {e}")
return f"{len(squad_uuids)} шт."
async def send_maintenance_status_notification(
self,
event_type: str,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
if event_type == "enable":
if details.get("auto_enabled", False):
icon = "⚠️"
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "warning"
else:
icon = "🔧"
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "info"
elif event_type == "disable":
icon = ""
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "success"
elif event_type == "api_status":
if status == "online":
icon = "🟢"
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
alert_type = "success"
else:
icon = "🔴"
title = "API REMNAWAVE НЕДОСТУПНО"
alert_type = "error"
elif event_type == "monitoring":
if status == "started":
icon = "🔍"
title = "МОНИТОРИНГ ЗАПУЩЕН"
alert_type = "info"
else:
icon = "⏹️"
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
alert_type = "info"
else:
icon = ""
title = "СИСТЕМА ТЕХРАБОТ"
alert_type = "info"
message_parts = [f"{icon} <b>{title}</b>", ""]
if event_type == "enable":
if details.get("reason"):
message_parts.append(f"📋 <b>Причина:</b> {details['reason']}")
if details.get("enabled_at"):
enabled_at = details["enabled_at"]
if isinstance(enabled_at, str):
from datetime import datetime
enabled_at = datetime.fromisoformat(enabled_at)
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
message_parts.append("")
message_parts.append("❗ Обычные пользователи временно не могут использовать бота.")
elif event_type == "disable":
if details.get("disabled_at"):
disabled_at = details["disabled_at"]
if isinstance(disabled_at, str):
from datetime import datetime
disabled_at = datetime.fromisoformat(disabled_at)
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
if details.get("duration"):
duration = details["duration"]
if isinstance(duration, (int, float)):
hours = int(duration // 3600)
minutes = int((duration % 3600) // 60)
if hours > 0:
duration_str = f"{hours}ч {minutes}мин"
else:
duration_str = f"{minutes}мин"
message_parts.append(f"⏱️ <b>Длительность:</b> {duration_str}")
message_parts.append(f"🤖 <b>Было автоматическим:</b> {'Да' if details.get('was_auto', False) else 'Нет'}")
message_parts.append("")
message_parts.append("✅ Сервис снова доступен для пользователей.")
elif event_type == "api_status":
message_parts.append(f"🔗 <b>API URL:</b> {details.get('api_url', 'неизвестно')}")
if status == "online":
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("consecutive_failures", 0) > 0:
message_parts.append(f"🔄 <b>Неудачных попыток было:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("API снова отвечает на запросы.")
else:
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Попытка №:</b> {details['consecutive_failures']}")
if details.get("error"):
error_msg = str(details["error"])[:100]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
message_parts.append("")
message_parts.append("⚠️ Началась серия неудачных проверок API.")
elif event_type == "monitoring":
if status == "started":
if details.get("check_interval"):
message_parts.append(f"🔄 <b>Интервал проверки:</b> {details['check_interval']} сек")
if details.get("auto_enable_configured") is not None:
auto_enable = "Включено" if details["auto_enable_configured"] else "Отключено"
message_parts.append(f"🤖 <b>Автовключение:</b> {auto_enable}")
if details.get("max_failures"):
message_parts.append(f"🎯 <b>Порог ошибок:</b> {details['max_failures']}")
message_parts.append("")
message_parts.append("Система будет следить за доступностью API.")
else:
message_parts.append("Автоматический мониторинг API остановлен.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о техработах: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
if details.get("manual_message"):
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
+78 -28
View File
@@ -33,7 +33,6 @@ class MaintenanceService:
self._last_notification_sent = None
def set_bot(self, bot):
"""Устанавливает ссылку на бота для отправки уведомлений"""
self._bot = bot
logger.info("Бот установлен для maintenance_service")
@@ -58,12 +57,49 @@ class MaintenanceService:
else:
return settings.get_maintenance_message()
async def _send_admin_notification(self, message: str, alert_type: str = "info"):
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return False
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self._bot)
if not notification_service._is_enabled():
logger.debug("Уведомления администраторов отключены")
return False
emoji_map = {
"error": "🚨",
"warning": "⚠️",
"success": "",
"info": ""
}
emoji = emoji_map.get(alert_type, "")
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
return await notification_service._send_message(formatted_message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
return False
async def _notify_admins(self, message: str, alert_type: str = "info"):
"""Отправка уведомлений администраторам"""
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return
notification_sent = await self._send_admin_notification(message, alert_type)
if notification_sent:
logger.info("Уведомление успешно отправлено через AdminNotificationService")
return
logger.info("Отправляем уведомление напрямую администраторам")
cache_key = f"maintenance_notification_{alert_type}"
if await cache.get(cache_key):
return
@@ -116,15 +152,13 @@ class MaintenanceService:
await self._save_status_to_cache()
notification_msg = f"""
Режим технических работ ВКЛЮЧЕН
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 <b>Причина:</b> {self._status.reason}
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
Обычные пользователи временно не смогут использовать бота.
"""
Обычные пользователи временно не смогут использовать бота."""
await self._notify_admins(notification_msg, "warning" if auto else "info")
@@ -163,15 +197,13 @@ class MaintenanceService:
else:
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
notification_msg = f"""
Режим технических работ ВЫКЛЮЧЕН
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
{duration_str}
Сервис снова доступен для пользователей.
"""
Сервис снова доступен для пользователей."""
await self._notify_admins(notification_msg, "success")
@@ -191,17 +223,15 @@ class MaintenanceService:
await self._load_status_from_cache()
self._check_task = asyncio.create_task(self._monitoring_loop())
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
await self._notify_admins(f"""
Мониторинг технических работ запущен
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}
Система будет следить за доступностью API.
""", "info")
Система будет следить за доступностью API.""", "info")
return True
@@ -240,17 +270,14 @@ class MaintenanceService:
is_connected = await test_api_connection(api)
if is_connected:
# API восстановилось
if not self._status.api_status:
await self._notify_admins(f"""
API RemnaWave восстановлено!
await self._notify_admins(f"""API Remnawave восстановлено!
<b>Статус:</b> Доступно
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
API снова отвечает на запросы.
""", "success")
API снова отвечает на запросы.""", "success")
self._status.api_status = True
self._status.consecutive_failures = 0
@@ -266,15 +293,13 @@ API снова отвечает на запросы.
self._status.consecutive_failures += 1
if was_available:
await self._notify_admins(f"""
API RemnaWave недоступно!
await self._notify_admins(f"""API Remnawave недоступно!
<b>Статус:</b> Недоступно
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
Началась серия неудачных проверок API.
""", "error")
Началась серия неудачных проверок API.""", "error")
if (self._status.consecutive_failures >= self._max_consecutive_failures and
not self._status.is_active and
@@ -291,14 +316,12 @@ API RemnaWave недоступно!
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
await self._notify_admins(f"""
Ошибка при проверке API RemnaWave
await self._notify_admins(f"""Ошибка при проверке API Remnawave
<b>Ошибка:</b> {str(e)}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
Не удалось выполнить проверку доступности API.
""", "error")
Не удалось выполнить проверку доступности API.""", "error")
self._status.api_status = False
self._status.consecutive_failures += 1
@@ -401,6 +424,33 @@ API RemnaWave недоступно!
"checked_at": end_time,
"consecutive_failures": self._status.consecutive_failures
}
async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool:
try:
status_emojis = {
"online": "🟢",
"offline": "🔴",
"warning": "🟡",
"error": "⚠️"
}
emoji = status_emojis.get(status, "")
message = f"""Статус панели Remnawave изменился
{emoji} <b>Статус:</b> {status.upper()}
🔗 <b>URL:</b> {settings.REMNAWAVE_API_URL}
{details}"""
alert_type = "error" if status in ["offline", "error"] else "info"
await self._notify_admins(message, alert_type)
logger.info(f"Отправлено уведомление о статусе Remnawave: {status}")
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}")
return False
maintenance_service = MaintenanceService()
+14 -12
View File
@@ -48,7 +48,7 @@ class PaymentService:
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
)
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽)")
return invoice_link
except Exception as e:
@@ -90,7 +90,7 @@ class PaymentService:
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
description_for_referral = f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
description_for_referral = f"Пополнение Stars: {int(rubles_amount)}₽ ({stars_amount} ⭐)"
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
@@ -108,7 +108,7 @@ class PaymentService:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user, transaction, old_balance
db, user, transaction, old_balance
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
@@ -125,13 +125,15 @@ class PaymentService:
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}")
logger.info(
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
logger.info(
f"✅ Обработан Stars платеж: пользователь {user_id}, "
f"{stars_amount} звезд → {rubles_amount:.2f}"
f"{stars_amount} звезд → {int(rubles_amount)}"
)
return True
else:
@@ -297,7 +299,7 @@ class PaymentService:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user, transaction, old_balance
db, user, transaction, old_balance
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
@@ -313,7 +315,7 @@ class PaymentService:
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks/100:.2f}")
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
else:
@@ -352,7 +354,7 @@ class PaymentService:
user = await get_user_by_id(db, payment.user_id)
if user:
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks/100:.2f}")
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}")
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}")
@@ -429,17 +431,17 @@ class PaymentService:
signature: str
) -> bool:
if not settings.TRIBUTE_WEBHOOK_SECRET:
if not settings.TRIBUTE_API_KEY:
return False
try:
message = str(data).encode()
expected_signature = hmac.new(
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
settings.TRIBUTE_API_KEY.encode(),
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as e:
+19 -15
View File
@@ -63,7 +63,8 @@ async def process_referral_registration(
f"👥 <b>Новый реферал!</b>\n\n"
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
f"вы получите {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
@@ -78,7 +79,7 @@ async def process_referral_registration(
async def process_referral_topup(
db: AsyncSession,
user_id: int,
user_id: int,
topup_amount_kopeks: int,
bot: Bot = None
):
@@ -105,12 +106,12 @@ async def process_referral_topup(
await db.execute(
delete(ReferralEarning).where(
ReferralEarning.user_id == referrer.id,
ReferralEarning.referral_id == user_id,
ReferralEarning.referral_id == user.id,
ReferralEarning.reason == "referral_registration_pending"
)
)
await db.commit()
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user_id}")
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user.id}")
except Exception as e:
logger.error(f"Ошибка удаления записи ожидания: {e}")
@@ -120,7 +121,7 @@ async def process_referral_topup(
f"Бонус за первое пополнение по реферальной программе",
bot=bot
)
logger.info(f"💰 Реферал {user_id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}")
logger.info(f"💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}")
if bot:
bonus_notification = (
@@ -131,27 +132,30 @@ async def process_referral_topup(
)
await send_referral_notification(bot, user.telegram_id, bonus_notification)
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
if inviter_bonus > 0:
await add_user_balance(
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
db, referrer, inviter_bonus,
f"Бонус за первое пополнение реферала {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
referral_id=user.id,
amount_kopeks=inviter_bonus,
reason="referral_first_topup"
)
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}")
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {inviter_bonus/100}")
if bot:
inviter_bonus_notification = (
f"💰 <b>Реферальная награда!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
@@ -170,7 +174,7 @@ async def process_referral_topup(
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission_topup"
)
@@ -230,7 +234,7 @@ async def process_referral_purchase(
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission",
referral_transaction_id=transaction_id
+218 -13
View File
@@ -200,7 +200,7 @@ class RemnaWaveService:
return result
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API при получении статистики: {e}")
logger.error(f"Ошибка Remnawave API при получении статистики: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Общая ошибка получения системной статистики: {e}")
@@ -271,11 +271,11 @@ class RemnaWaveService:
'traffic_limit_bytes': node.traffic_limit_bytes
})
logger.info(f"✅ Получено {len(result)} нод из RemnaWave")
logger.info(f"✅ Получено {len(result)} нод из Remnawave")
return result
except Exception as e:
logger.error(f"Ошибка получения нод из RemnaWave: {e}")
logger.error(f"Ошибка получения нод из Remnawave: {e}")
return []
async def test_connection(self) -> bool:
@@ -283,11 +283,11 @@ class RemnaWaveService:
try:
async with self.api as api:
stats = await api.get_system_stats()
logger.info("✅ Соединение с RemnaWave API работает")
logger.info("✅ Соединение с Remnawave API работает")
return True
except Exception as e:
logger.error(f"❌ Ошибка соединения с RemnaWave API: {e}")
logger.error(f"❌ Ошибка соединения с Remnawave API: {e}")
return False
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
@@ -378,11 +378,11 @@ class RemnaWaveService:
'inbounds': squad.inbounds
})
logger.info(f"✅ Получено {len(result)} сквадов из RemnaWave")
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
return result
except Exception as e:
logger.error(f"Ошибка получения сквадов из RemnaWave: {e}")
logger.error(f"Ошибка получения сквадов из Remnawave: {e}")
return []
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
@@ -1119,9 +1119,6 @@ class RemnaWaveService:
return False
async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
"""
Усиленная версия очистки с полным удалением данных
"""
try:
stats = {"deactivated": 0, "errors": 0, "checked": 0}
@@ -1286,10 +1283,10 @@ class RemnaWaveService:
if rw_user:
subscription.remnawave_short_uuid = rw_user.short_uuid
subscription.subscription_url = rw_user.subscription_url
logger.info(f"🔧 Восстановлены данные RemnaWave для {user.telegram_id}")
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
issues_fixed += 1
except Exception as rw_error:
logger.warning(f"⚠️ Не удалось получить данные RemnaWave для {user.telegram_id}: {rw_error}")
logger.warning(f"⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}")
if subscription.traffic_limit_gb < 0:
subscription.traffic_limit_gb = 0
@@ -1356,7 +1353,7 @@ class RemnaWaveService:
recommendations["should_sync"] = True
recommendations["sync_type"] = "all"
recommendations["priority"] = "high"
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с RemnaWave")
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с Remnawave")
recommendations["estimated_time"] = "3-5 минут"
if active_expired > 5:
@@ -1382,3 +1379,211 @@ class RemnaWaveService:
"priority": "medium",
"estimated_time": "3-5 минут"
}
async def monitor_panel_status(self, bot) -> Dict[str, Any]:
try:
from app.utils.cache import cache
previous_status = await cache.get("remnawave_panel_status") or "unknown"
status_result = await self.check_panel_health()
current_status = status_result.get("status", "offline")
if current_status != previous_status and previous_status != "unknown":
await self._send_status_change_notification(
bot,
previous_status,
current_status,
status_result
)
await cache.set("remnawave_panel_status", current_status, expire=300)
return status_result
except Exception as e:
logger.error(f"Ошибка мониторинга статуса панели Remnawave: {e}")
return {"status": "error", "error": str(e)}
async def _send_status_change_notification(
self,
bot,
old_status: str,
new_status: str,
status_data: Dict[str, Any]
):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
details = {
"api_url": status_data.get("api_url"),
"response_time": status_data.get("response_time"),
"last_check": status_data.get("last_check"),
"users_online": status_data.get("users_online"),
"nodes_online": status_data.get("nodes_online"),
"total_nodes": status_data.get("total_nodes"),
"old_status": old_status
}
if new_status == "offline":
details["error"] = status_data.get("api_error")
elif new_status == "degraded":
issues = []
if status_data.get("response_time", 0) > 10:
issues.append(f"Медленный отклик API ({status_data.get('response_time')}с)")
if status_data.get("nodes_health") == "unhealthy":
issues.append(f"Проблемы с нодами ({status_data.get('nodes_online')}/{status_data.get('total_nodes')} онлайн)")
details["issues"] = issues
await notification_service.send_remnawave_panel_status_notification(
new_status,
details
)
logger.info(f"Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении статуса: {e}")
async def send_manual_status_notification(self, bot, status: str, message: str = ""):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
details = {
"api_url": settings.REMNAWAVE_API_URL,
"last_check": datetime.utcnow(),
"manual_message": message
}
if status == "maintenance":
details["maintenance_reason"] = message or "Плановое обслуживание"
await notification_service.send_remnawave_panel_status_notification(status, details)
logger.info(f"Отправлено ручное уведомление о статусе панели: {status}")
return True
except Exception as e:
logger.error(f"Ошибка отправки ручного уведомления: {e}")
return False
async def get_panel_status_summary(self) -> Dict[str, Any]:
try:
status_data = await self.check_panel_health()
status_descriptions = {
"online": "🟢 Панель работает нормально",
"offline": "🔴 Панель недоступна",
"degraded": "🟡 Панель работает со сбоями",
"maintenance": "🔧 Панель на обслуживании"
}
status = status_data.get("status", "offline")
summary = {
"status": status,
"description": status_descriptions.get(status, "❓ Статус неизвестен"),
"response_time": status_data.get("response_time", 0),
"api_available": status_data.get("api_available", False),
"nodes_status": f"{status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)} нод онлайн",
"users_online": status_data.get("users_online", 0),
"last_check": status_data.get("last_check"),
"has_issues": status in ["offline", "degraded"]
}
if status == "offline":
summary["recommendation"] = "Проверьте подключение к серверу и работоспособность панели"
elif status == "degraded":
summary["recommendation"] = "Рекомендуется проверить состояние нод и производительность сервера"
else:
summary["recommendation"] = "Все системы работают нормально"
return summary
except Exception as e:
logger.error(f"Ошибка получения сводки статуса панели: {e}")
return {
"status": "error",
"description": "❌ Ошибка проверки статуса",
"response_time": 0,
"api_available": False,
"nodes_status": "неизвестно",
"users_online": 0,
"last_check": datetime.utcnow(),
"has_issues": True,
"recommendation": "Обратитесь к системному администратору",
"error": str(e)
}
async def check_panel_health(self) -> Dict[str, Any]:
try:
start_time = datetime.utcnow()
async with self.api as api:
try:
system_stats = await api.get_system_stats()
api_available = True
api_error = None
except Exception as e:
api_available = False
api_error = str(e)
system_stats = {}
try:
nodes = await api.get_all_nodes()
nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online)
total_nodes = len(nodes)
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
except Exception:
nodes_online = 0
total_nodes = 0
nodes_health = "unknown"
end_time = datetime.utcnow()
response_time = (end_time - start_time).total_seconds()
if not api_available:
status = "offline"
elif response_time > 10:
status = "degraded"
elif nodes_health == "unhealthy":
status = "degraded"
else:
status = "online"
return {
"status": status,
"api_available": api_available,
"api_error": api_error,
"response_time": round(response_time, 2),
"nodes_online": nodes_online,
"total_nodes": total_nodes,
"nodes_health": nodes_health,
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
"last_check": end_time,
"api_url": settings.REMNAWAVE_API_URL
}
except Exception as e:
logger.error(f"Ошибка проверки здоровья панели: {e}")
return {
"status": "offline",
"api_available": False,
"api_error": str(e),
"response_time": 0,
"nodes_online": 0,
"total_nodes": 0,
"nodes_health": "unknown",
"last_check": datetime.utcnow(),
"api_url": settings.REMNAWAVE_API_URL
}
+52 -44
View File
@@ -1,6 +1,4 @@
import logging
import hashlib
import hmac
import json
from typing import Optional, Dict, Any
from datetime import datetime
@@ -56,15 +54,9 @@ class TributeService:
async def process_webhook(
self,
payload: str,
signature: Optional[str] = None
payload: str
) -> Dict[str, Any]:
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
if not self.tribute_api.verify_webhook_signature(payload, signature):
logger.warning("Неверная подпись Tribute webhook")
return {"status": "error", "reason": "invalid_signature"}
try:
webhook_data = json.loads(payload)
except json.JSONDecodeError:
@@ -90,37 +82,38 @@ class TributeService:
return {"status": "ok", "event": event_type}
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
try:
user_id = payment_data["user_id"]
user_telegram_id = payment_data["user_id"]
amount_kopeks = payment_data["amount_kopeks"]
payment_id = payment_data["payment_id"]
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}")
async for session in get_db():
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
duplicate_transaction = await check_tribute_payment_duplicate(
session, payment_id, amount_kopeks, user_id
)
if duplicate_transaction:
logger.warning(f"Найден дубликат платежа:")
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
logger.warning(f" Created: {duplicate_transaction.created_at}")
logger.warning(f"Платеж игнорирован")
return
user = await get_user_by_telegram_id(session, user_id)
user = await get_user_by_telegram_id(session, user_telegram_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
logger.error(f"Пользователь {user_telegram_id} не найден")
return
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
from app.database.crud.transaction import check_tribute_payment_duplicate
duplicate_transaction = await check_tribute_payment_duplicate(
session, payment_id, amount_kopeks, user_telegram_id
)
if duplicate_transaction:
logger.warning(f"Найден дубликат платежа в течение 24ч:")
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
logger.warning(f" Created: {duplicate_transaction.created_at}")
logger.warning(f" External ID: {duplicate_transaction.external_id}")
logger.warning(f"Платеж игнорирован - это дубликат свежего платежа")
return
from app.database.crud.transaction import create_unique_tribute_transaction
transaction = await create_unique_tribute_transaction(
db=session,
user_id=user.id,
@@ -135,18 +128,38 @@ class TributeService:
await session.commit()
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
logger.info(f"Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
await self._send_success_notification(user_id, amount_kopeks)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
if not user.has_made_first_topup:
user.has_made_first_topup = True
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
session, user, transaction, old_balance
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
await self._send_success_notification(user_telegram_id, amount_kopeks)
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
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:
user_id = payment_data["user_id"]
@@ -170,7 +183,6 @@ class TributeService:
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
async def _handle_refund(self, refund_data: Dict[str, Any]):
"""Обработка возврата"""
try:
user_id = refund_data["user_id"]
@@ -203,14 +215,13 @@ class TributeService:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления об успешном платеже"""
try:
amount_rubles = amount_kopeks / 100
text = (
f"✅ **Платеж успешно получен!**\n\n"
f"💰 Сумма: {amount_rubles:.2f}\n"
f"💰 Сумма: {int(amount_rubles)}\n"
f"💳 Способ оплаты: Tribute\n"
f"🎉 Средства зачислены на баланс!\n\n"
f"Спасибо за оплату! 🙏"
@@ -232,11 +243,10 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
"""Отправка уведомления о неудачном платеже"""
try:
text = (
" **Платеж не прошел**\n\n"
" **Платеж не прошел**\n\n"
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
"Возможные причины:\n"
"• Недостаточно средств на карте\n"
@@ -261,14 +271,13 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления о возврате"""
try:
amount_rubles = amount_kopeks / 100
text = (
f"🔄 **Возврат средств**\n\n"
f"💰 Сумма возврата: {amount_rubles:.2f}\n"
f"💰 Сумма возврата: {int(amount_rubles)}\n"
f"💳 Способ: Tribute\n\n"
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
@@ -296,7 +305,6 @@ class TributeService:
amount_kopeks: int,
description: str = "Принудительная обработка Tribute платежа"
) -> bool:
"""Принудительная обработка платежа (для отладки)"""
try:
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
@@ -304,7 +312,7 @@ class TributeService:
async for session in get_db():
user = await get_user_by_telegram_id(session, user_id)
if not user:
logger.error(f" Пользователь {user_id} не найден")
logger.error(f" Пользователь {user_id} не найден")
return False
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
@@ -334,7 +342,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) -> Optional[Dict[str, Any]]:
+267
View File
@@ -0,0 +1,267 @@
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import aiohttp
from packaging import version
import re
from app.config import settings
logger = logging.getLogger(__name__)
class VersionInfo:
def __init__(self, tag_name: str, published_at: str, name: str, body: str, prerelease: bool = False):
self.tag_name = tag_name
self.published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00'))
self.name = name or tag_name
self.body = body
self.prerelease = prerelease
self.is_dev = 'dev' in tag_name.lower()
@property
def clean_version(self) -> str:
return re.sub(r'^v', '', self.tag_name)
@property
def version_obj(self):
try:
clean_ver = self.clean_version
if 'dev' in clean_ver:
base_ver = clean_ver.split('-dev')[0]
return version.parse(f"{base_ver}.dev")
return version.parse(clean_ver)
except Exception:
return version.parse("0.0.0")
@property
def formatted_date(self) -> str:
return self.published_at.strftime('%d.%m.%Y %H:%M')
@property
def short_description(self) -> str:
if not self.body:
return "Без описания"
description = self.body.strip()
if len(description) > 350:
description = description[:347] + "..."
return description
class VersionService:
def __init__(self, bot=None):
self.bot = bot
self.repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
self.enabled = getattr(settings, 'VERSION_CHECK_ENABLED', True)
self.current_version = self._get_current_version()
self.cache_ttl = 3600
self._cache: Dict = {}
self._last_check: Optional[datetime] = None
self._notification_service = None
async def get_latest_stable_version(self) -> str:
try:
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
data = await response.json()
return data['tag_name']
except Exception:
pass
return "UNKNOW"
def _get_current_version(self) -> str:
import os
current = os.getenv('VERSION', '').strip()
if current:
if '-' in current and current.startswith('v'):
base_version = current.split('-')[0]
if base_version.count('.') == 2:
return base_version
return current
return "UNKNOW"
def set_notification_service(self, notification_service):
self._notification_service = notification_service
async def check_for_updates(self, force: bool = False) -> Tuple[bool, List[VersionInfo]]:
if not self.enabled:
return False, []
try:
releases = await self._fetch_releases(force)
if not releases:
return False, []
current_ver = self._parse_version(self.current_version)
newer_releases = []
for release in releases:
release_ver = release.version_obj
if release_ver > current_ver:
newer_releases.append(release)
newer_releases.sort(key=lambda x: x.version_obj, reverse=True)
has_updates = len(newer_releases) > 0
if has_updates and not force:
await self._send_update_notification(newer_releases)
return has_updates, newer_releases
except Exception as e:
logger.error(f"Ошибка проверки обновлений: {e}")
return False, []
async def _fetch_releases(self, force: bool = False) -> List[VersionInfo]:
if not force and self._cache and self._last_check:
if datetime.now() - self._last_check < timedelta(seconds=self.cache_ttl):
return self._cache.get('releases', [])
url = f"https://api.github.com/repos/{self.repo}/releases"
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for release_data in data[:20]:
release = VersionInfo(
tag_name=release_data['tag_name'],
published_at=release_data['published_at'],
name=release_data['name'],
body=release_data['body'] or '',
prerelease=release_data['prerelease']
)
releases.append(release)
self._cache['releases'] = releases
self._last_check = datetime.now()
logger.info(f"Получено {len(releases)} релизов из GitHub")
return releases
else:
logger.warning(f"GitHub API вернул статус {response.status}")
return []
except asyncio.TimeoutError:
logger.warning("Таймаут при запросе к GitHub API")
return []
except Exception as e:
logger.error(f"Ошибка запроса к GitHub API: {e}")
return []
def _parse_version(self, version_str: str):
try:
clean_ver = re.sub(r'^v', '', version_str)
if 'dev' in clean_ver:
base_ver = clean_ver.split('-dev')[0]
return version.parse(f"{base_ver}.dev")
if 'unknow' in clean_ver.lower():
return version.parse("0.0.0")
return version.parse(clean_ver)
except Exception:
return version.parse("0.0.0")
async def _send_update_notification(self, newer_releases: List[VersionInfo]):
if not self._notification_service or not newer_releases:
return
try:
latest_version = newer_releases[0]
cache_key = f"notified_{latest_version.tag_name}"
if self._cache.get(cache_key):
return
await self._notification_service.send_version_update_notification(
current_version=self.current_version,
latest_version=latest_version,
total_updates=len(newer_releases)
)
self._cache[cache_key] = True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
async def get_version_info(self) -> Dict:
try:
has_updates, newer_releases = await self.check_for_updates()
all_releases = await self._fetch_releases()
current_release = None
current_ver = self._parse_version(self.current_version)
for release in all_releases:
if release.version_obj == current_ver:
current_release = release
break
return {
'current_version': self.current_version,
'current_release': current_release,
'has_updates': has_updates,
'newer_releases': newer_releases[:5],
'total_newer': len(newer_releases),
'last_check': self._last_check,
'repo_url': f"https://github.com/{self.repo}"
}
except Exception as e:
logger.error(f"Ошибка получения информации о версиях: {e}")
return {
'current_version': self.current_version,
'current_release': None,
'has_updates': False,
'newer_releases': [],
'total_newer': 0,
'last_check': None,
'repo_url': f"https://github.com/{self.repo}",
'error': str(e)
}
async def start_periodic_check(self):
if not self.enabled:
logger.info("Проверка версий отключена")
return
logger.info(f"Запуск периодической проверки обновлений для {self.repo}")
logger.info(f"Текущая версия: {self.current_version}")
while True:
try:
await asyncio.sleep(3600)
await self.check_for_updates()
except asyncio.CancelledError:
logger.info("Остановка проверки обновлений")
break
except Exception as e:
logger.error(f"Ошибка в периодической проверке обновлений: {e}")
await asyncio.sleep(300)
def format_version_display(self, version_info: VersionInfo) -> str:
status_icon = ""
if version_info.prerelease:
status_icon = "🧪"
elif version_info.is_dev:
status_icon = "🔧"
else:
status_icon = "📦"
return f"{status_icon} {version_info.tag_name}"
version_service = VersionService()
+75 -6
View File
@@ -1,7 +1,18 @@
import re
from typing import Optional, Union
from datetime import datetime
import html
ALLOWED_HTML_TAGS = {
'b', 'strong',
'i', 'em',
'u', 'ins',
's', 'strike', 'del',
'code',
'pre',
'a',
'blockquote'
}
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
@@ -105,13 +116,24 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
def sanitize_html(text: str) -> str:
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
if not text:
return text
for tag in allowed_tags:
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
text = html.escape(text)
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
for tag in ALLOWED_HTML_TAGS:
text = re.sub(
f'&lt;{tag}(&gt;|\\s[^&]*&gt;)',
lambda m: m.group(0).replace('&lt;', '<').replace('&gt;', '>'),
text,
flags=re.IGNORECASE
)
text = re.sub(
f'&lt;/{tag}&gt;',
f'</{tag}>',
text,
flags=re.IGNORECASE
)
return text
@@ -134,4 +156,51 @@ def validate_referral_code(code: str) -> bool:
user_id_part = code[3:]
return user_id_part.isdigit()
return validate_promocode(code)
return validate_promocode(code)
def validate_html_tags(text: str) -> tuple[bool, str]:
if not text:
return True, ""
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>'
tags = re.findall(tag_pattern, text)
for is_closing, tag_name in tags:
tag_name_lower = tag_name.lower()
if tag_name_lower not in ALLOWED_HTML_TAGS:
return False, f"Неподдерживаемый тег: <{tag_name}>"
tag_stack = []
for is_closing, tag_name in tags:
tag_name_lower = tag_name.lower()
if not is_closing:
tag_stack.append(tag_name_lower)
else:
if not tag_stack:
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
last_tag = tag_stack.pop()
if last_tag != tag_name_lower:
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
if tag_stack:
return False, f"Незакрытый тег: <{tag_stack[-1]}>"
return True, ""
def get_html_help_text() -> str:
return """<b>Поддерживаемые HTML теги:</b>
- <code>&lt;b&gt;жирный&lt;/b&gt;</code> или <code>&lt;strong&gt;жирный&lt;/strong&gt;</code>
- <code>&lt;i&gt;курсив&lt;/i&gt;</code> или <code>&lt;em&gt;курсив&lt;/em&gt;</code>
- <code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code>
- <code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code>
- <code>&lt;code&gt;моноширинный&lt;/code&gt;</code>
- <code>&lt;pre&gt;блок кода&lt;/pre&gt;</code>
- <code>&lt;a href="url"&gt;ссылка&lt;/a&gt;</code>
- <code>&lt;blockquote&gt;цитата&lt;/blockquote&gt;</code>
<b>Неподдерживаемые теги:</b> &lt;br&gt;, &lt;p&gt;, &lt;div&gt;, &lt;span&gt;, &lt;spoiler&gt; и другие"""
+27 -10
View File
@@ -7,7 +7,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
@@ -23,7 +23,7 @@ services:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
@@ -46,11 +46,25 @@ services:
env_file:
- .env
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
REDIS_URL: redis://redis:6379/0
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
volumes:
# Логи
- ./logs:/app/logs:rw
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
@@ -58,16 +72,18 @@ services:
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
- bot_network
user: "1000:1000"
command: >
bash -c "
mkdir -p /app/logs /app/data &&
python main.py
"
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
bot_network:
@@ -75,3 +91,4 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
+38 -2
View File
@@ -13,6 +13,7 @@ from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
@@ -49,6 +50,7 @@ async def main():
yookassa_server_task = None
monitoring_task = None
maintenance_task = None
version_check_task = None
polling_task = None
try:
@@ -77,7 +79,15 @@ async def main():
bot, dp = await setup_bot()
monitoring_service.bot = bot
maintenance_service.set_bot(bot)
maintenance_service.set_bot(bot)
from app.services.admin_notification_service import AdminNotificationService
admin_notification_service = AdminNotificationService(bot)
version_service.bot = bot
version_service.set_notification_service(admin_notification_service)
logger.info(f"🔄 Сервис версий настроен для репозитория: {version_service.repo}")
logger.info(f"📦 Текущая версия: {version_service.current_version}")
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
payment_service = PaymentService(bot)
@@ -108,7 +118,13 @@ async def main():
logger.info("🔧 Служба техработ уже запущена")
maintenance_task = None
logger.info("🔄 Запуск polling...")
if settings.is_version_check_enabled():
logger.info("🔄 Запуск сервиса проверки версий...")
version_check_task = asyncio.create_task(version_service.start_periodic_check())
else:
logger.info("ℹ️ Проверка версий отключена")
logger.info("📄 Запуск polling...")
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
logger.info("=" * 50)
@@ -117,6 +133,10 @@ async def main():
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_yookassa_enabled():
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info("🔄 Активные фоновые сервисы:")
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
logger.info("=" * 50)
try:
@@ -143,6 +163,14 @@ async def main():
if exception:
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
if version_check_task and version_check_task.done():
exception = version_check_task.exception()
if exception:
logger.error(f"Сервис проверки версий завершился с ошибкой: {exception}")
if settings.is_version_check_enabled():
logger.info("🔄 Перезапуск сервиса проверки версий...")
version_check_task = asyncio.create_task(version_service.start_periodic_check())
if polling_task.done():
exception = polling_task.exception()
@@ -186,6 +214,14 @@ async def main():
except asyncio.CancelledError:
pass
if version_check_task and not version_check_task.done():
logger.info("ℹ️ Остановка сервиса проверки версий...")
version_check_task.cancel()
try:
await version_check_task
except asyncio.CancelledError:
pass
if polling_task and not polling_task.done():
logger.info("ℹ️ Остановка polling...")
polling_task.cancel()
+4
View File
@@ -25,3 +25,7 @@ APScheduler==3.10.4
python-dateutil==2.8.2
pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2
# Для работы с версиями
packaging==23.2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB