Compare commits

..

61 Commits

Author SHA1 Message Date
Egor 16ae480430 Update Dockerfile 2025-09-10 00:26:52 +03:00
Egor 0b222c06a1 Update docker-hub.yml 2025-09-10 00:26:21 +03:00
Egor ff340e90d6 Update docker-registry.yml 2025-09-10 00:26:02 +03:00
Egor 4125141d01 Merge pull request #49 from yazhog/main
Режим link для ссылки подписки, фикс напоминаний о истечении подписки
2025-09-10 00:06:38 +03:00
yazhog 2f40ad53b5 Merge pull request #10 from yazhog/codex/implement-notification-tracking-system
Persist subscription expiry notifications
2025-09-09 23:36:56 +03:00
yazhog 2d3674eff7 Cascade delete sent notifications 2025-09-09 23:29:06 +03:00
yazhog 560e60b856 Persist subscription expiry notifications 2025-09-09 19:13:15 +03:00
yazhog 7cf20234b8 Merge branch 'Fr1ngg:main' into main 2025-09-09 18:52:42 +03:00
yazhog ecab0a0f45 Merge pull request #9 from yazhog/codex/add-connect_button_mode-for-link-subscription
feat: add link connect button mode
2025-09-09 18:42:32 +03:00
yazhog 587722ed5c feat: add link connect button mode 2025-09-09 13:32:20 +03:00
Egor bd7d9b3d5e Update docker-registry.yml 2025-09-09 11:05:30 +03:00
Egor 60787a2aa9 Merge pull request #48 from yazhog/main
Тест подписка сразу после старта бота, продление в любой момент
2025-09-09 10:38:59 +03:00
yazhog 9675ac3c14 Allow subscription renewal anytime 2025-09-09 10:21:13 +03:00
yazhog ca9a2f5997 Add extend subscription button for paid users 2025-09-09 10:21:13 +03:00
yazhog 652eb4f5a7 Respect renewal period settings in extension keyboard 2025-09-09 10:21:13 +03:00
yazhog 6ffbad747d Правка текстов 2025-09-09 10:21:13 +03:00
yazhog af46c596a9 Фикс текста 2025-09-09 10:21:13 +03:00
yazhog 1fcc218f26 Тексты поменял 2025-09-09 10:21:00 +03:00
yazhog 39a5d674c9 feat: add greeting and VPN advantages to trial offer 2025-09-09 10:19:25 +03:00
yazhog bdfe6ae1cf feat: add trial prompt after registration 2025-09-09 10:19:25 +03:00
yazhog 06e6c65ada Убрал про Ваш реферер получит... 2025-09-09 10:19:25 +03:00
Egor 8a7e4ff1c9 Update docker-registry.yml 2025-09-09 09:10:16 +03:00
Egor f0a1e1c338 Update docker-hub.yml 2025-09-09 09:09:57 +03:00
Egor 843af67483 Update Dockerfile 2025-09-09 09:09:31 +03:00
Egor 80d251c2d1 Update README.md 2025-09-09 09:02:04 +03:00
Egor a1c2907cce Update webhook_server.py 2025-09-09 08:56:15 +03:00
Egor c1369f113c Update README.md 2025-09-09 08:20:12 +03:00
Egor 8ea5bc5330 Update README.md 2025-09-09 08:18:02 +03:00
Egor 285dda4795 Merge pull request #47 from Fr1ngg/cp
Корректное удаление записей криптобота
2025-09-09 08:12:37 +03:00
Egor fae423177b Update user_service.py 2025-09-09 08:11:28 +03:00
Egor 51bf43289d Merge pull request #46 from Fr1ngg/cp
Интеграция Cryptobot
2025-09-09 08:03:32 +03:00
Egor 3fd87eee9a Update README.md 2025-09-09 08:00:57 +03:00
Egor ad14ab5d08 Update universal_migration.py 2025-09-09 07:42:45 +03:00
Egor aab68767fd Update payment_service.py 2025-09-09 07:31:52 +03:00
Egor 84f10229da Update balance.py 2025-09-09 07:27:10 +03:00
Egor 3a6ce31103 Create currency_converter.py 2025-09-09 07:25:39 +03:00
Egor da7675a853 Update payment_service.py 2025-09-09 07:14:33 +03:00
Egor 09726f8df0 Update universal_migration.py 2025-09-09 07:04:49 +03:00
Egor 8072ce6954 Update universal_migration.py 2025-09-09 06:59:37 +03:00
Egor 1e870e49c8 Update universal_migration.py 2025-09-09 06:55:32 +03:00
Egor eeca712a3e Update webhook_server.py 2025-09-09 06:48:30 +03:00
Egor 29c3e455e4 Update .env.example 2025-09-09 06:33:21 +03:00
Egor c23a777783 Update webhook_server.py 2025-09-09 06:32:10 +03:00
Egor 0439279602 Update inline.py 2025-09-09 06:31:21 +03:00
Egor 27c81d451b Update balance.py 2025-09-09 06:30:11 +03:00
Egor 339881f288 Update payment_service.py 2025-09-09 06:27:50 +03:00
Egor ef6bfb8ea3 Update models.py 2025-09-09 06:22:31 +03:00
Egor 4bd33fbbe2 Update config.py 2025-09-09 06:21:12 +03:00
Egor 8a5f097808 Update config.py 2025-09-09 06:20:26 +03:00
Egor 239eb9705d Create cryptobot.py 2025-09-09 06:19:32 +03:00
Egor fee141ea2b Create cryptobot.py 2025-09-09 06:18:24 +03:00
Egor ec7c121e03 Update user_service.py 2025-09-09 05:32:33 +03:00
Egor 8b047fd18c Update user_service.py 2025-09-09 05:31:42 +03:00
Egor 0c04868bef Update universal_migration.py 2025-09-09 05:26:10 +03:00
Egor deaf16e6ae Update user_service.py 2025-09-09 05:18:23 +03:00
Egor 389cac3e0a Create .dockerignore 2025-09-09 04:30:52 +03:00
Egor c04968818f Update Dockerfile 2025-09-09 04:29:55 +03:00
Egor 3a391d79d2 Update docker-registry.yml 2025-09-09 04:29:46 +03:00
Egor d1a8019c31 Update models.py 2025-09-08 20:22:38 +03:00
Egor 1c211b0a41 Update README.md 2025-09-08 20:16:31 +03:00
Egor 7c1ba6d92b Update README.md 2025-09-08 10:33:50 +03:00
27 changed files with 1745 additions and 376 deletions
+46
View File
@@ -0,0 +1,46 @@
# Git files
.git
.gitignore
.github/
# Documentation
README.md
*.md
docs/
# Development files
.vscode/
.idea/
*.pyc
__pycache__/
.pytest_cache/
.coverage
htmlcov/
# Environment files
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Docker files (исключаем из копирования в образ)
Dockerfile*
.dockerignore
docker-compose*.yml
# Build artifacts
build/
dist/
*.egg-info/
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
+13
View File
@@ -176,12 +176,25 @@ PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# CRYPTOBOT
CRYPTOBOT_ENABLED=true
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
CRYPTOBOT_WEBHOOK_PORT=8083
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# ===== ИНТЕРФЕЙС И UX =====
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.4-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-$(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)"
VERSION="v2.2.6-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)"
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+44 -19
View File
@@ -30,8 +30,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -42,29 +46,36 @@ jobs:
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"
VERSION="v2.2.6"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.4-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.4-pr-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-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 "==========================="
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
fi
- name: Extract metadata
id: meta
@@ -86,20 +97,26 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
platforms: linux/amd64
push: ${{ steps.version.outputs.should_push }}
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
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-contexts: |
alpine=docker-image://alpine:latest
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -110,7 +127,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
if: github.event_name != 'pull_request'
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
@@ -122,8 +139,16 @@ jobs:
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $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 "| **Статус** | ✅ Собран успешно (без публикации) |" >> $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
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
+1
View File
@@ -2,6 +2,7 @@
*
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!Dockerfile
!app-config.json
+38 -24
View File
@@ -1,46 +1,60 @@
FROM python:3.11-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
ARG VERSION="v2.2.4"
ARG VERSION="v2.2.6"
ARG BUILD_DATE
ARG VCS_REF
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
COPY . .
WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && \
chown -R app:app /app && \
chown -R 1000:1000 ./logs ./data
chown -R app:app /app logs data
USER app
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV VERSION=${VERSION}
ENV BUILD_DATE=${BUILD_DATE}
ENV VCS_REF=${VCS_REF}
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
VERSION=${VERSION} \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
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"
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
+37 -7
View File
@@ -35,10 +35,10 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + ЮKassa
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** - Уведомление в топик канала об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
### 🎛️ **Гибкость конфигурации**
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор при нескольких
@@ -309,6 +309,18 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# CRYPTOBOT
CRYPTOBOT_ENABLED=true
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
CRYPTOBOT_WEBHOOK_PORT=8083
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
@@ -350,6 +362,7 @@ PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
@@ -390,6 +403,10 @@ 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
```
</details>
@@ -749,13 +766,14 @@ bedolaga_bot/
│ │ ├── 🎫 promocodes.py # Управление промокодами
│ │ ├── 🚧 maintenance.py # Тех работы
│ │ ├── 📨 messages.py # Рассылки
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
│ │ ├── ⚙️ main.py # Админское меню
│ │ ├── 📖 rules.py # Правила
│ │ ├── 🙋 referrals.py # Правила
│ │ ├── 🌎 servers.py # Сервера
│ │ ├── 📱 subscriptions.py # Подписки
│ │ ├── 🔍 monitoring.py # Мониторинг
│ │ └── 🔗 remnawave.py # Система RemnaWave
│ │ └── 🔗 remnawave.py # Система Remnawave
│ │
│ ├── 🗄️ database/ # База данных
│ │ ├── 📊 models.py # Модели SQLAlchemy
@@ -768,6 +786,7 @@ bedolaga_bot/
│ │ ├── 📜 rules.py # Правила сервиса
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
│ │ ├── 💳 yookassa.py # YooKassa операции
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Рефералы
@@ -781,9 +800,10 @@ bedolaga_bot/
│ │ ├── 👥 referral_service.py # Рефералы
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
│ │ └── 🌐 remnawave_service.py # Интеграция с RemnaWave
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
│ │
│ ├── 🛠️ utils/ # Утилиты
│ │ ├── 🎨 decorators.py # Декораторы
@@ -793,6 +813,7 @@ bedolaga_bot/
│ │ ├── 📄 pagination.py # Пагинация
│ │ ├── 📄 pricing_utils.py # Цены
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
│ │ └── ⚡ cache.py # Кеширование
│ │
│ ├── 🛡️ middlewares/ # Middleware
@@ -812,10 +833,11 @@ bedolaga_bot/
│ │ └── 👑 admin.py # Админские клавиатуры
│ │
│ └── 🔌 external/ # Внешние API
│ ├── 🌐 remnawave_api.py # RemnaWave API
│ ├── 🌐 remnawave_api.py # Remnawave API
│ ├── ⭐ telegram_stars.py # Telegram Stars
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
│ ├── 🌐 webhook_server.py # Webhook сервер
│ ├── 💳 cryptobot.py # CryptoBot Api
│ └── 🎖️ tribute.py # Tribute платежи
├── 🔄 migrations/ # Миграции БД
@@ -882,6 +904,14 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# CryptoBot webhook endpoint
handle /cryptobot-webhook* {
reverse_proxy localhost:8081 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
# Для YooKassa
location /yookassa-webhook {
@@ -942,7 +972,7 @@ your-domain.com {
## 🛡️ Безопасность
### 🔐 Защита панели RemnaWave
### 🔐 Защита панели Remnawave
Бот поддерживает интеграцию с системой защиты панели через куки-аутентификацию:
@@ -1008,7 +1038,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<tr>
<td>🥇</td>
<td><strong>@pilot_737800</strong></td>
<td>₽2,750</td>
<td>₽4,750</td>
<td>За веру в проект с самого начала</td>
</tr>
+32
View File
@@ -125,6 +125,17 @@ class Settings(BaseSettings):
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
CRYPTOBOT_ENABLED: bool = False
CRYPTOBOT_API_TOKEN: Optional[str] = None
CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None
CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot"
CRYPTOBOT_TESTNET: bool = False
CRYPTOBOT_WEBHOOK_PATH: str = "/cryptobot-webhook"
CRYPTOBOT_WEBHOOK_PORT: int = 8083
CRYPTOBOT_DEFAULT_ASSET: str = "USDT"
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -284,6 +295,27 @@ class Settings(BaseSettings):
return f"{self.WEBHOOK_URL}/payment-success"
return "https://t.me/"
def is_cryptobot_enabled(self) -> bool:
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
return self.CRYPTOBOT_BASE_URL
def get_cryptobot_assets(self) -> List[str]:
try:
assets = self.CRYPTOBOT_ASSETS.strip()
if not assets:
return ["USDT", "TON"]
return [asset.strip() for asset in assets.split(',') if asset.strip()]
except (ValueError, AttributeError):
return ["USDT", "TON"]
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
+156
View File
@@ -0,0 +1,156 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
async def create_cryptobot_payment(
db: AsyncSession,
user_id: int,
invoice_id: str,
amount: str,
asset: str,
status: str = "active",
description: Optional[str] = None,
payload: Optional[str] = None,
bot_invoice_url: Optional[str] = None,
mini_app_invoice_url: Optional[str] = None,
web_app_invoice_url: Optional[str] = None
) -> CryptoBotPayment:
payment = CryptoBotPayment(
user_id=user_id,
invoice_id=invoice_id,
amount=amount,
asset=asset,
status=status,
description=description,
payload=payload,
bot_invoice_url=bot_invoice_url,
mini_app_invoice_url=mini_app_invoice_url,
web_app_invoice_url=web_app_invoice_url
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
return payment
async def get_cryptobot_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.invoice_id == invoice_id)
)
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id(
db: AsyncSession,
payment_id: int
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_cryptobot_payment_status(
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: Optional[datetime] = None
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.status = status
payment.updated_at = datetime.utcnow()
if status == "paid" and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
return payment
async def link_cryptobot_payment_to_transaction(
db: AsyncSession,
invoice_id: str,
transaction_id: int
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
return payment
async def get_user_cryptobot_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
.order_by(CryptoBotPayment.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_pending_cryptobot_payments(
db: AsyncSession,
older_than_hours: int = 24
) -> List[CryptoBotPayment]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(
and_(
CryptoBotPayment.status == "active",
CryptoBotPayment.created_at < cutoff_time
)
)
.order_by(CryptoBotPayment.created_at)
)
return result.scalars().all()
+52
View File
@@ -0,0 +1,52 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
async def notification_sent(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> bool:
result = await db.execute(
select(SentNotification).where(
SentNotification.user_id == user_id,
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
SentNotification.days_before == days_before,
)
)
return result.scalar_one_or_none() is not None
async def record_notification(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> None:
notification = SentNotification(
user_id=user_id,
subscription_id=subscription_id,
notification_type=notification_type,
days_before=days_before,
)
db.add(notification)
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id
)
)
await db.commit()
+5 -3
View File
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Subscription, SubscriptionStatus, User,
Subscription, SubscriptionStatus, User,
SubscriptionServer
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
@@ -121,10 +122,11 @@ async def extend_subscription(
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
+65 -8
View File
@@ -45,6 +45,7 @@ class PaymentMethod(Enum):
TELEGRAM_STARS = "telegram_stars"
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
MANUAL = "manual"
class YooKassaPayment(Base):
@@ -95,6 +96,55 @@ class YooKassaPayment(Base):
def __repr__(self):
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
class CryptoBotPayment(Base):
__tablename__ = "cryptobot_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
asset = Column(String(10), nullable=False)
status = Column(String(50), nullable=False)
description = Column(Text, nullable=True)
payload = Column(Text, nullable=True)
bot_invoice_url = Column(Text, nullable=True)
mini_app_invoice_url = Column(Text, nullable=True)
web_app_invoice_url = Column(Text, nullable=True)
paid_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="cryptobot_payments")
transaction = relationship("Transaction", backref="cryptobot_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (ValueError, TypeError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status == "paid"
@property
def is_pending(self) -> bool:
return self.status == "active"
@property
def is_expired(self) -> bool:
return self.status == "expired"
def __repr__(self):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class User(Base):
__tablename__ = "users"
@@ -505,6 +555,20 @@ class MonitoringLog(Base):
created_at = Column(DateTime, default=func.now())
class SentNotification(Base):
__tablename__ = "sent_notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(String(50), nullable=False)
days_before = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="sent_notifications")
subscription = relationship("Subscription", backref="sent_notifications")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
@@ -585,20 +649,13 @@ class SubscriptionServer(Base):
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_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), 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):
+188 -122
View File
@@ -74,11 +74,11 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
return False
async def create_yookassa_payments_table():
table_exists = await check_table_exists('yookassa_payments')
async def create_cryptobot_payments_table():
"""Создание таблицы cryptobot_payments"""
table_exists = await check_table_exists('cryptobot_payments')
if table_exists:
logger.info("Таблица yookassa_payments уже существует")
logger.info("Таблица cryptobot_payments уже существует")
return True
try:
@@ -87,159 +87,211 @@ async def create_yookassa_payments_table():
if db_type == 'sqlite':
create_sql = """
CREATE TABLE yookassa_payments (
CREATE TABLE cryptobot_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
invoice_id VARCHAR(255) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
asset VARCHAR(10) NOT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT 0,
is_captured BOOLEAN DEFAULT 0,
confirmation_url TEXT NULL,
metadata_json TEXT NULL,
description TEXT NULL,
payload TEXT NULL,
bot_invoice_url TEXT NULL,
mini_app_invoice_url TEXT NULL,
web_app_invoice_url TEXT NULL,
paid_at DATETIME NULL,
transaction_id INTEGER NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT 0,
test_mode BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
yookassa_created_at DATETIME NULL,
captured_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE yookassa_payments (
CREATE TABLE cryptobot_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
invoice_id VARCHAR(255) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
asset VARCHAR(10) NOT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSONB NULL,
description TEXT NULL,
payload TEXT NULL,
bot_invoice_url TEXT NULL,
mini_app_invoice_url TEXT NULL,
web_app_invoice_url TEXT NULL,
paid_at TIMESTAMP NULL,
transaction_id INTEGER NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT FALSE,
test_mode BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
yookassa_created_at TIMESTAMP NULL,
captured_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE yookassa_payments (
CREATE TABLE cryptobot_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INT NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
invoice_id VARCHAR(255) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
asset VARCHAR(10) NOT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSON NULL,
description TEXT NULL,
payload TEXT NULL,
bot_invoice_url TEXT NULL,
mini_app_invoice_url TEXT NULL,
web_app_invoice_url TEXT NULL,
paid_at DATETIME NULL,
transaction_id INT NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT FALSE,
test_mode BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
yookassa_created_at DATETIME NULL,
captured_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица yookassa_payments успешно создана")
logger.info("Таблица cryptobot_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы yookassa_payments: {e}")
logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}")
return False
async def add_remnawave_v2_columns():
columns_to_add = {
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
'last_remnawave_sync': 'TIMESTAMP NULL',
'trojan_password': 'VARCHAR(255) NULL',
'vless_uuid': 'VARCHAR(255) NULL',
'ss_password': 'VARCHAR(255) NULL'
}
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
async def create_user_messages_table():
"""Создание таблицы user_messages"""
table_exists = await check_table_exists('user_messages')
if table_exists:
logger.info("Таблица user_messages уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
columns_added = 0
for column_name, column_def in columns_to_add.items():
exists = await check_column_exists('users', column_name)
if db_type == 'sqlite':
create_sql = """
CREATE TABLE user_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_text TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_by INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
if not exists:
logger.info(f"Добавление колонки {column_name} в таблицу users")
if db_type == 'sqlite':
if column_def.startswith('BIGINT'):
column_def = column_def.replace('BIGINT', 'INTEGER')
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
elif db_type == 'mysql':
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
try:
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
columns_added += 1
logger.info(f"Колонка {column_name} успешно добавлена")
except Exception as e:
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
continue
else:
logger.debug(f"Колонка {column_name} уже существует")
if columns_added > 0:
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE user_messages (
id SERIAL PRIMARY KEY,
message_text TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_by INTEGER NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE user_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
message_text TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
"""
else:
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
return columns_added
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица user_messages успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
return 0
logger.error(f"Ошибка создания таблицы user_messages: {e}")
return False
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'postgresql':
try:
await conn.execute(text("""
ALTER TABLE user_messages
DROP CONSTRAINT IF EXISTS user_messages_created_by_fkey;
"""))
await conn.execute(text("""
ALTER TABLE user_messages
ADD CONSTRAINT user_messages_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
"""))
logger.info("Обновлен внешний ключ user_messages.created_by")
except Exception as e:
logger.warning(f"Ошибка обновления FK user_messages: {e}")
try:
await conn.execute(text("""
ALTER TABLE promocodes
DROP CONSTRAINT IF EXISTS promocodes_created_by_fkey;
"""))
await conn.execute(text("""
ALTER TABLE promocodes
ADD CONSTRAINT promocodes_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
"""))
logger.info("Обновлен внешний ключ promocodes.created_by")
except Exception as e:
logger.warning(f"Ошибка обновления FK promocodes: {e}")
logger.info("Внешние ключи обновлены для безопасного удаления пользователей")
return True
except Exception as e:
logger.error(f"Ошибка обновления внешних ключей: {e}")
return False
async def add_referral_system_columns():
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
@@ -292,7 +344,7 @@ async def add_referral_system_columns():
return False
async def create_subscription_conversions_table():
"""Создание таблицы subscription_conversions"""
table_exists = await check_table_exists('subscription_conversions')
if table_exists:
logger.info("Таблица subscription_conversions уже существует")
@@ -368,7 +420,7 @@ async def create_subscription_conversions_table():
return False
async def fix_subscription_duplicates_universal():
"""Исправление дублирующихся подписок"""
async with engine.begin() as conn:
db_type = await get_database_type()
logger.info(f"Обнаружен тип базы данных: {db_type}")
@@ -453,19 +505,36 @@ async def run_universal_migration():
db_type = await get_database_type()
logger.info(f"Тип базы данных: {db_type}")
await add_remnawave_v2_columns()
# Миграция реферальной системы
referral_migration_success = await add_referral_system_columns()
if not referral_migration_success:
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
yookassa_created = await create_yookassa_payments_table()
if yookassa_created:
logger.info("✅ Таблица YooKassa payments готова")
# Создание таблицы CryptoBot payments
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
cryptobot_created = await create_cryptobot_payments_table()
if cryptobot_created:
logger.info("✅ Таблица CryptoBot payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
# Создание таблицы user_messages
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
user_messages_created = await create_user_messages_table()
if user_messages_created:
logger.info("✅ Таблица user_messages готова")
else:
logger.warning("⚠️ Проблемы с таблицей user_messages")
# Обновление внешних ключей
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
logger.info("✅ Внешние ключи обновлены")
else:
logger.warning("⚠️ Проблемы с обновлением внешних ключей")
# Создание таблицы конверсий подписок
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
conversions_created = await create_subscription_conversions_table()
if conversions_created:
@@ -473,6 +542,7 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
# Проверка и исправление дублирующихся подписок
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -490,6 +560,7 @@ async def run_universal_migration():
deleted_count = await fix_subscription_duplicates_universal()
# Финальная проверка
async with engine.begin() as conn:
final_check = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
@@ -506,8 +577,7 @@ async def run_universal_migration():
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("RemnaWave v2.1.5 колонки добавлены")
logger.info("✅ YooKassa таблица готова")
logger.info("CryptoBot таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Дубликаты подписок исправлены")
return True
@@ -522,25 +592,21 @@ async def check_migration_status():
try:
status = {
"has_made_first_topup_column": False,
"yookassa_table": False,
"remnawave_v2_columns": False,
"cryptobot_table": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
}
# Проверка колонки реферальной системы
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["yookassa_table"] = await check_table_exists('yookassa_payments')
# Проверка таблицы CryptoBot
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
# Проверка таблицы конверсий подписок
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
remnawave_status = []
for col in remnawave_columns:
exists = await check_column_exists('users', col)
remnawave_status.append(exists)
status["remnawave_v2_columns"] = all(remnawave_status)
# Проверка дублирующихся подписок
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
@@ -553,11 +619,11 @@ async def check_migration_status():
duplicates_count = duplicates_check.fetchone()[0]
status["subscription_duplicates"] = (duplicates_count == 0)
# Вывод результатов
check_names = {
"has_made_first_topup_column": "Колонка реферальной системы",
"yookassa_table": "Таблица YooKassa payments",
"cryptobot_table": "Таблица CryptoBot payments",
"subscription_conversions_table": "Таблица конверсий подписок",
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
+167
View File
@@ -0,0 +1,167 @@
import logging
import hashlib
import hmac
import json
import aiohttp
from typing import Optional, Dict, Any
from datetime import datetime
from app.config import settings
logger = logging.getLogger(__name__)
class CryptoBotService:
def __init__(self):
self.api_token = settings.CRYPTOBOT_API_TOKEN
self.base_url = settings.get_cryptobot_base_url()
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
) -> Optional[Dict[str, Any]]:
if not self.api_token:
logger.error("CryptoBot API token не настроен")
return None
url = f"{self.base_url}/api/{endpoint}"
headers = {
'Crypto-Pay-API-Token': self.api_token,
'Content-Type': 'application/json'
}
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method,
url,
headers=headers,
json=data if data else None
) as response:
response_data = await response.json()
if response.status == 200 and response_data.get('ok'):
return response_data.get('result')
else:
logger.error(f"CryptoBot API ошибка: {response_data}")
return None
except Exception as e:
logger.error(f"Ошибка запроса к CryptoBot API: {e}")
return None
async def get_me(self) -> Optional[Dict[str, Any]]:
return await self._make_request('GET', 'getMe')
async def create_invoice(
self,
amount: str,
asset: str = "USDT",
description: Optional[str] = None,
payload: Optional[str] = None,
expires_in: Optional[int] = None
) -> Optional[Dict[str, Any]]:
data = {
'currency_type': 'crypto',
'asset': asset,
'amount': amount
}
if description:
data['description'] = description
if payload:
data['payload'] = payload
if expires_in:
data['expires_in'] = expires_in
result = await self._make_request('POST', 'createInvoice', data)
if result:
logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}")
return result
async def get_invoices(
self,
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
async def get_exchange_rates(self) -> Optional[list]:
return await self._make_request('GET', 'getExchangeRates')
def verify_webhook_signature(self, body: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("CryptoBot webhook secret не настроен")
return True
try:
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ CryptoBot webhook подпись валидна")
else:
logger.error("❌ Неверная подпись CryptoBot webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}")
return False
async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
update_type = webhook_data.get('update_type')
if update_type == 'invoice_paid':
invoice_data = webhook_data.get('payload', {})
return {
'event_type': 'payment',
'payment_id': str(invoice_data.get('invoice_id')),
'amount': invoice_data.get('amount'),
'asset': invoice_data.get('asset'),
'status': 'paid',
'user_payload': invoice_data.get('payload'),
'paid_at': invoice_data.get('paid_at'),
'payment_system': 'cryptobot'
}
logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}")
return None
except Exception as e:
logger.error(f"Ошибка обработки CryptoBot webhook: {e}")
return None
+99 -21
View File
@@ -25,12 +25,20 @@ class WebhookServer:
self.app = web.Application()
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
@@ -52,11 +60,13 @@ class WebhookServer:
await self.site.start()
logger.info(f"✅ Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
except Exception as e:
logger.error(f"Ошибка запуска Tribute webhook сервера: {e}")
logger.error(f"Ошибка запуска webhook сервера: {e}")
raise
async def stop(self):
@@ -64,14 +74,14 @@ class WebhookServer:
try:
if self.site:
await self.site.stop()
logger.info("Tribute webhook сайт остановлен")
logger.info("Webhook сайт остановлен")
if self.runner:
await self.runner.cleanup()
logger.info("Tribute webhook runner очищен")
logger.info("Webhook runner очищен")
except Exception as e:
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
logger.error(f"Ошибка остановки webhook сервера: {e}")
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
@@ -79,43 +89,43 @@ class WebhookServer:
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
}
)
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("⚠️ Получен пустой webhook от Tribute")
logger.warning("Получен пустой webhook от Tribute")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"📄 Payload: {payload}")
logger.info(f"Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
logger.info(f"Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON: {e}")
logger.error(f"Ошибка парсинга JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('trbt-signature')
logger.info(f"🔐 Signature: {signature}")
logger.info(f"Signature: {signature}")
if not signature:
logger.error("Отсутствует заголовок подписи Tribute webhook")
logger.error("Отсутствует заголовок подписи Tribute webhook")
return web.json_response(
{"status": "error", "reason": "missing_signature"},
status=401
@@ -125,7 +135,7 @@ class WebhookServer:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись Tribute webhook")
logger.error("Неверная подпись Tribute webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
@@ -134,17 +144,83 @@ class WebhookServer:
result = await self.tribute_service.process_webhook(payload)
if result:
logger.info(f"Tribute webhook обработан успешно: {result}")
logger.info(f"Tribute webhook обработан успешно: {result}")
return web.json_response({"status": "ok", "result": result}, status=200)
else:
logger.error("Ошибка обработки Tribute webhook")
logger.error("Ошибка обработки Tribute webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Получен пустой CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"CryptoBot Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"CryptoBot данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('Crypto-Pay-API-Signature')
logger.info(f"CryptoBot Signature: {signature}")
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
from app.external.cryptobot import CryptoBotService
cryptobot_service = CryptoBotService()
if not cryptobot_service.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
from app.services.payment_service import PaymentService
from app.database.database import AsyncSessionLocal
payment_service = PaymentService(self.bot)
async with AsyncSessionLocal() as db:
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
if result:
logger.info(f"CryptoBot webhook обработан успешно")
return web.json_response({"status": "ok"}, status=200)
else:
logger.error("Ошибка обработки CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
@@ -154,8 +230,10 @@ class WebhookServer:
return web.json_response({
"status": "ok",
"service": "tribute-webhooks",
"service": "payment-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"port": settings.TRIBUTE_WEBHOOK_PORT,
"path": settings.TRIBUTE_WEBHOOK_PATH
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
})
+211
View File
@@ -337,6 +337,10 @@ async def process_topup_amount(
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
@@ -526,6 +530,203 @@ async def check_yookassa_payment_status(
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
@error_handler
async def start_cryptobot_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
return
from app.utils.currency_converter import currency_converter
try:
current_rate = await currency_converter.get_usd_to_rub_rate()
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f}"
except Exception as e:
logger.warning(f"Не удалось получить курс валют: {e}")
current_rate = 95.0
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f}"
available_assets = settings.get_cryptobot_assets()
assets_text = ", ".join(available_assets)
await callback.message.edit_text(
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Введите сумму для пополнения в рублях от 100 до 100,000 ₽:\n\n"
f"💰 Доступные активы: {assets_text}\n"
f"⚡ Мгновенное зачисление на баланс\n"
f"🔒 Безопасная оплата через CryptoBot\n\n"
f"{rate_text}\n"
f"Сумма будет автоматически конвертирована в USD для оплаты.",
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
await callback.answer()
@error_handler
async def process_cryptobot_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
try:
# Получаем курс из состояния или запрашиваем заново
data = await state.get_data()
current_rate = data.get('current_rate')
if not current_rate:
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
# Конвертируем рубли в доллары
amount_usd = amount_rubles / current_rate
# Округляем до 2 знаков после запятой
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
return
if amount_usd > 1000:
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
return
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_cryptobot_payment(
db=db,
user_id=db_user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
bot_invoice_url = payment_result.get("bot_invoice_url")
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
payment_url = bot_invoice_url or mini_app_invoice_url
if not payment_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
f"🪙 Актив: {payment_result['asset']}\n"
f"💱 Курс: 1 USD = {current_rate:.2f}\n"
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить'\n"
f"2. Выберите удобный актив\n"
f"3. Переведите указанную сумму\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
except Exception as e:
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_cryptobot_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"active": "",
"paid": "",
"expired": ""
}
status_text = {
"active": "Ожидает оплаты",
"paid": "Оплачен",
"expired": "Истек"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"🪙 Статус платежа:\n\n"
f"🆔 ID: {payment.invoice_id[:8]}...\n"
f"💰 Сумма: {payment.amount} {payment.asset}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_paid:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_expired:
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
def register_handlers(dp: Dispatcher):
@@ -579,3 +780,13 @@ def register_handlers(dp: Dispatcher):
process_topup_amount,
BalanceStates.waiting_for_amount
)
dp.callback_query.register(
start_cryptobot_payment,
F.data == "topup_cryptobot"
)
dp.callback_query.register(
check_cryptobot_payment_status,
F.data.startswith("check_cryptobot_")
)
+36 -86
View File
@@ -12,7 +12,7 @@ from app.database.crud.user import (
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
)
from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
@@ -498,52 +498,28 @@ async def complete_registration_from_callback(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
offer_text = (
f"<b>Привет, {user.full_name}!</b>\n\n"
f"🎁 <b>{settings.TRIAL_DURATION_DAYS} дней VPN бесплатно!</b>\n"
"Подключайтесь за минуту и забудьте о блокировках.\n\n"
"✅ До <b>1 Гбит/с</b> скорость\n"
"✅ Умный VPN — можно не отключать для большинства российских сервисов\n"
"✅ Современные протоколы — максимум защиты и анонимности\n\n"
f"👉 Всего <b>{int(settings.PRICE_30_DAYS/100)}₽/мес за 1 устройство</b>\n\n"
"👇 Жмите кнопку и подключайтесь!"
)
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
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=user.balance_kopeks
),
parse_mode="HTML"
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = user.balance_kopeks // 100
await callback.message.answer(
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=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
)
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.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except Exception as final_error:
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
logger.error(f"Ошибка при отправке предложения триала: {e}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
async def complete_registration(
@@ -661,53 +637,27 @@ async def complete_registration(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
user_name = message.from_user.first_name or message.from_user.username or "друг"
offer_text = (
f"Привет, {user_name}!\n\n"
f"Подключите VPN бесплатно! Дарим вам {settings.TRIAL_DURATION_DAYS} дней!\n\n"
"Наши преимущества:\n"
" • Высокая скорость соединения — до 1гб/с\n"
" • Умный VPN — можно не отключать для большинства российских сервисов\n"
" • Самые современные протоколы — высокая защита от блокировки\n\n"
"Стоимость 100₽/мес за 1 устройство\n\n"
"👇Жмите на кнопку👇 чтобы подключить в 1 клик"
)
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
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=user.balance_kopeks
),
parse_mode="HTML"
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = user.balance_kopeks // 100
await message.answer(
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=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
except Exception as fallback_error:
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except:
pass
logger.error(f"Ошибка при отправке предложения триала: {e}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
+40 -38
View File
@@ -34,8 +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_insufficient_balance_keyboard
get_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices,
)
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
@@ -45,7 +45,8 @@ from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation
validate_pricing_calculation,
format_period_description,
)
logger = logging.getLogger(__name__)
@@ -415,6 +416,12 @@ async def activate_trial(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "link":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
[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")],
@@ -808,8 +815,6 @@ async def handle_extend_subscription(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, format_period_description
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -817,10 +822,6 @@ async def handle_extend_subscription(
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
return
if subscription.days_left > 3:
await callback.answer("⚠ Продление доступно за 3 дня до окончания подписки", show_alert=True)
return
subscription_service = SubscriptionService()
available_periods = settings.get_available_renewal_periods()
@@ -1229,34 +1230,6 @@ async def confirm_extend_subscription(
await callback.answer()
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📅 30 дней - {texts.format_price(prices[30])}",
callback_data="extend_period_30"
)
],
[
InlineKeyboardButton(
text=f"📅 90 дней - {texts.format_price(prices[90])}",
callback_data="extend_period_90"
)
],
[
InlineKeyboardButton(
text=f"📅 180 дней - {texts.format_price(prices[180])}",
callback_data="extend_period_180"
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
async def confirm_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -1953,6 +1926,12 @@ async def confirm_purchase(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "link":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
[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")],
@@ -2720,7 +2699,30 @@ async def handle_connect_subscription(
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "link":
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Подключиться",
url=subscription.subscription_url
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
await callback.message.edit_text(
f"""
🚀 <b>Подключить подписку</b>
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:
""",
reply_markup=keyboard,
parse_mode="HTML"
)
else:
device_text = f"""
📱 <b>Подключить подписку</b>
+69 -25
View File
@@ -5,6 +5,7 @@ from datetime import datetime
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.texts import get_texts
from app.utils.pricing_utils import format_period_description
import logging
logger = logging.getLogger(__name__)
@@ -19,8 +20,19 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
)
],
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
])
def get_main_menu_keyboard(
language: str = "ru",
language: str = "ru",
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
@@ -127,7 +139,7 @@ def get_subscription_keyboard(
texts = get_texts(language)
keyboard = []
if has_subscription:
if subscription and subscription.subscription_url:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -151,21 +163,23 @@ def get_subscription_keyboard(
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
])
elif connect_mode == "link":
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
])
else:
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
])
if not is_trial and subscription and subscription.days_left <= 3:
keyboard.append([
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
])
if not is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
])
keyboard.append([
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
])
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
@@ -445,6 +459,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(
text="🪙 Криптовалюта (CryptoBot)",
callback_data="topup_cryptobot"
)
])
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(
@@ -518,7 +540,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="⏰ Продлить подписку",
text=texts.MENU_EXTEND_SUBSCRIPTION,
callback_data="subscription_extend"
)
],
@@ -1039,29 +1061,51 @@ def get_specific_app_keyboard(
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_renewal_periods()
period_display = {
14: "14 дней",
30: "30 дней",
60: "60 дней",
90: "90 дней",
180: "180 дней",
360: "360 дней"
}
for days in available_periods:
if days in prices and days in period_display:
if days in prices:
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
callback_data=f"extend_period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_cryptobot_payment_keyboard(
payment_id: str,
local_payment_id: int,
amount_usd: float,
asset: str,
bot_invoice_url: str,
language: str = "ru"
) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🪙 Оплатить",
url=bot_invoice_url
)
],
[
InlineKeyboardButton(
text="📊 Проверить статус",
callback_data=f"check_cryptobot_{local_payment_id}"
)
],
[
InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)
]
])
+1 -1
View File
@@ -281,7 +281,7 @@ class RussianTexts(Texts):
30 дней всего за {price}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+19 -17
View File
@@ -17,6 +17,10 @@ from app.database.crud.user import (
get_user_by_id, get_inactive_users, delete_user,
subtract_user_balance
)
from app.database.crud.notification import (
notification_sent,
record_notification,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
@@ -186,31 +190,30 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
user_key = f"user_{user.telegram_id}_today"
if (notification_key in self._notified_users or
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
user_key in all_processed_users):
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
continue
should_send = True
for other_days in warning_days:
if other_days < days:
if other_days < days:
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
if any(s.user_id == user.id for s in other_subs):
should_send = False
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
break
if not should_send:
continue
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
self._notified_users.add(notification_key)
await record_notification(db, user.id, subscription.id, "expiring", days)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
@@ -249,15 +252,14 @@ class MonitoringService:
user = subscription.user
if not user:
continue
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
if notification_key in self._notified_users:
continue
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
continue
if self.bot:
success = await self._send_trial_ending_notification(user, subscription)
if success:
self._notified_users.add(notification_key)
await record_notification(db, user.id, subscription.id, "trial_2h")
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
@@ -453,13 +455,13 @@ class MonitoringService:
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+200
View File
@@ -14,6 +14,8 @@ from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_pa
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import TransactionType, PaymentMethod
from app.external.cryptobot import CryptoBotService
from app.utils.currency_converter import currency_converter
logger = logging.getLogger(__name__)
@@ -24,6 +26,7 @@ class PaymentService:
self.bot = bot
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
self.stars_service = TelegramStarsService(bot) if bot else None
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
async def create_stars_invoice(
self,
@@ -463,3 +466,200 @@ class PaymentService:
except Exception as e:
logger.error(f"Ошибка обработки платежа: {e}")
return False
async def create_cryptobot_payment(
self,
db: AsyncSession,
user_id: int,
amount_usd: float,
asset: str = "USDT",
description: str = "Пополнение баланса",
payload: Optional[str] = None
) -> Optional[Dict[str, Any]]:
if not self.cryptobot_service:
logger.error("CryptoBot сервис не инициализирован")
return None
try:
amount_str = f"{amount_usd:.2f}"
invoice_data = await self.cryptobot_service.create_invoice(
amount=amount_str,
asset=asset,
description=description,
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
expires_in=settings.get_cryptobot_invoice_expires_seconds()
)
if not invoice_data:
logger.error("Ошибка создания CryptoBot invoice")
return None
from app.database.crud.cryptobot import create_cryptobot_payment
local_payment = await create_cryptobot_payment(
db=db,
user_id=user_id,
invoice_id=str(invoice_data['invoice_id']),
amount=amount_str,
asset=asset,
status="active",
description=description,
payload=payload,
bot_invoice_url=invoice_data.get('bot_invoice_url'),
mini_app_invoice_url=invoice_data.get('mini_app_invoice_url'),
web_app_invoice_url=invoice_data.get('web_app_invoice_url')
)
logger.info(f"Создан CryptoBot платеж {invoice_data['invoice_id']} на {amount_str} {asset} для пользователя {user_id}")
return {
"local_payment_id": local_payment.id,
"invoice_id": str(invoice_data['invoice_id']),
"amount": amount_str,
"asset": asset,
"bot_invoice_url": invoice_data.get('bot_invoice_url'),
"mini_app_invoice_url": invoice_data.get('mini_app_invoice_url'),
"web_app_invoice_url": invoice_data.get('web_app_invoice_url'),
"status": "active",
"created_at": local_payment.created_at.isoformat() if local_payment.created_at else None
}
except Exception as e:
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
return None
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
from app.database.crud.cryptobot import (
get_cryptobot_payment_by_invoice_id,
update_cryptobot_payment_status,
link_cryptobot_payment_to_transaction
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
update_type = webhook_data.get("update_type")
if update_type != "invoice_paid":
logger.info(f"Пропуск CryptoBot webhook с типом: {update_type}")
return True
payload = webhook_data.get("payload", {})
invoice_id = str(payload.get("invoice_id"))
status = "paid"
if not invoice_id:
logger.error("CryptoBot webhook без invoice_id")
return False
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
logger.error(f"CryptoBot платеж не найден в БД: {invoice_id}")
return False
if payment.status == "paid":
logger.info(f"CryptoBot платеж {invoice_id} уже обработан")
return True
paid_at_str = payload.get("paid_at")
paid_at = None
if paid_at_str:
try:
paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None)
except:
paid_at = datetime.utcnow()
else:
paid_at = datetime.utcnow()
updated_payment = await update_cryptobot_payment_status(
db, invoice_id, status, paid_at
)
if not updated_payment.transaction_id:
# Получаем сумму в USD из платежа
amount_usd = updated_payment.amount_float
# Конвертируем в рубли по текущему курсу с улучшенной обработкой ошибок
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0
logger.info(f"Конвертация USD->RUB: ${amount_usd} -> {amount_rubles}₽ (курс: {conversion_rate:.2f})")
except Exception as e:
logger.warning(f"Ошибка конвертации валют для платежа {invoice_id}, используем курс 1:1: {e}")
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
# Проверяем корректность конвертированной суммы
if amount_kopeks <= 0:
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
return False
transaction = await create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через CryptoBot ({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)",
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
is_completed=True
)
await link_cryptobot_payment_to_transaction(
db, invoice_id, transaction.id
)
user = await get_user_by_id(db, updated_payment.user_id)
if user:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
if self.bot:
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
db, user, transaction, old_balance
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
if self.bot:
try:
await self.bot.send_message(
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
else:
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
return False
return True
except Exception as e:
logger.error(f"Ошибка обработки CryptoBot webhook: {e}", exc_info=True)
return False
+1 -1
View File
@@ -55,7 +55,7 @@ async def process_referral_registration(
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
)
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
+50 -1
View File
@@ -13,7 +13,7 @@ from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
)
from app.config import settings
@@ -246,6 +246,36 @@ class UserService:
except Exception as e:
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
try:
from app.database.models import UserMessage
from sqlalchemy import update
result = await db.execute(
update(UserMessage)
.where(UserMessage.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
try:
from app.database.models import PromoCode
from sqlalchemy import update
result = await db.execute(
update(PromoCode)
.where(PromoCode.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления промокодов: {e}")
try:
from app.database.models import YooKassaPayment
from sqlalchemy import select
@@ -264,6 +294,25 @@ class UserService:
logger.info(f"✅ YooKassa платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
try:
from app.database.models import CryptoBotPayment
from sqlalchemy import select, delete
cryptobot_result = await db.execute(
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
cryptobot_payments = cryptobot_result.scalars().all()
if cryptobot_payments:
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
await db.execute(
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
await db.flush()
logger.info(f"✅ CryptoBot платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
try:
transactions_result = await db.execute(
+121
View File
@@ -0,0 +1,121 @@
import logging
import aiohttp
import asyncio
from typing import Optional
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class CurrencyConverter:
def __init__(self):
self._cache = {}
self._cache_ttl = 3600 # 1 час
self._last_update = {}
async def get_usd_to_rub_rate(self) -> float:
"""Получает курс USD/RUB с кешированием"""
cache_key = "USD_RUB"
now = datetime.utcnow()
# Проверяем кеш
if (cache_key in self._cache and
cache_key in self._last_update and
(now - self._last_update[cache_key]).seconds < self._cache_ttl):
return self._cache[cache_key]
# Получаем новый курс
rate = await self._fetch_exchange_rate()
if rate:
self._cache[cache_key] = rate
self._last_update[cache_key] = now
logger.info(f"Обновлен курс USD/RUB: {rate}")
return rate
# Возвращаем из кеша если API недоступен
if cache_key in self._cache:
logger.warning("API курсов недоступен, используем кешированный курс")
return self._cache[cache_key]
# Fallback курс
logger.warning("Используем fallback курс USD/RUB: 95")
return 95.0
async def _fetch_exchange_rate(self) -> Optional[float]:
"""Получает курс с нескольких источников"""
sources = [
self._fetch_from_cbr,
self._fetch_from_exchangerate_api,
self._fetch_from_fixer
]
for source in sources:
try:
rate = await source()
if rate and 50 < rate < 200: # Разумные границы курса
return rate
except Exception as e:
logger.debug(f"Ошибка получения курса из {source.__name__}: {e}")
continue
return None
async def _fetch_from_cbr(self) -> Optional[float]:
"""Получает курс с сайта ЦБ РФ"""
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get('https://www.cbr-xml-daily.ru/daily_json.js') as response:
if response.status == 200:
data = await response.json()
usd_rate = data['Valute']['USD']['Value']
return float(usd_rate)
except Exception as e:
logger.debug(f"Ошибка получения курса ЦБ: {e}")
return None
async def _fetch_from_exchangerate_api(self) -> Optional[float]:
"""Получает курс с exchangerate-api.com"""
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get('https://api.exchangerate-api.com/v4/latest/USD') as response:
if response.status == 200:
data = await response.json()
rub_rate = data['rates']['RUB']
return float(rub_rate)
except Exception as e:
logger.debug(f"Ошибка получения курса exchangerate-api: {e}")
return None
async def _fetch_from_fixer(self) -> Optional[float]:
"""Получает курс с fixer.io (бесплатный план)"""
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
# Используем бесплатный endpoint (EUR base)
async with session.get('https://api.fixer.io/latest?access_key=YOUR_API_KEY&symbols=USD,RUB') as response:
if response.status == 200:
data = await response.json()
if data.get('success'):
# Конвертируем EUR -> USD -> RUB
usd_eur = data['rates']['USD']
rub_eur = data['rates']['RUB']
usd_rub = rub_eur / usd_eur
return float(usd_rub)
except Exception as e:
logger.debug(f"Ошибка получения курса fixer: {e}")
return None
async def usd_to_rub(self, usd_amount: float) -> float:
"""Конвертирует USD в RUB"""
rate = await self.get_usd_to_rub_rate()
return usd_amount * rate
async def rub_to_usd(self, rub_amount: float) -> float:
"""Конвертирует RUB в USD"""
rate = await self.get_usd_to_rub_rate()
return rub_amount / rate
# Глобальный экземпляр
currency_converter = CurrencyConverter()
@@ -0,0 +1,27 @@
"""add sent notifications table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '8fd1e338eb45'
down_revision: Union[str, None] = '3d9b35c6bd8f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'sent_notifications',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
sa.Column('notification_type', sa.String(length=50), nullable=False),
sa.Column('days_before', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
)
def downgrade() -> None:
op.drop_table('sent_notifications')
@@ -0,0 +1,24 @@
"""add cascade delete to sent notifications"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cbd1be472f3d'
down_revision: Union[str, None] = '8fd1e338eb45'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
def downgrade() -> None:
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])